Skip to content

Commit 75fa6c9

Browse files
committed
fix: reset ITK-wasm worker before loading labelmaps in session restore
The singleton ITK-wasm worker accumulates WASM heap across readImage calls. When loading a session with a large base image (~361MB) followed by a labelmap (~180MB), the heap can exceed 2GB. Emscripten's ccall returns pointers as signed i32, so pointers >2^31 wrap negative, causing RangeError. Reset the worker before deserializing segment groups to give each labelmap read a fresh WASM heap, preventing the overflow.
1 parent 529e212 commit 75fa6c9

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

src/io/import/processors/restoreStateFile.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useViewStore } from '@/src/store/views';
2020
import { useViewConfigStore } from '@/src/store/view-configs';
2121
import { migrateManifest } from '@/src/io/state-file/migrations';
2222
import { useMessageStore } from '@/src/store/messages';
23+
import { resetWorker } from '@/src/io/itk/worker';
2324

2425
type LeafSource =
2526
| { type: 'uri'; uri: string; name: string; mime?: string }
@@ -136,6 +137,12 @@ export async function completeStateFileRestore(
136137

137138
useViewConfigStore().deserializeAll(manifest, stateIDToStoreID);
138139

140+
// Reset the ITK-wasm worker to free accumulated WASM heap from loading
141+
// base images. Without this, loading large labelmaps on the same worker
142+
// can push the heap past 2GB, causing signed pointer overflow in
143+
// Emscripten's ccall (pointers > 2^31 wrap negative).
144+
await resetWorker();
145+
139146
const segmentGroupIDMap = await useSegmentGroupStore().deserialize(
140147
manifest,
141148
stateFiles,

src/io/itk/worker.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ export function getWorker() {
3333
return webWorker;
3434
}
3535

36+
export async function resetWorker() {
37+
if (webWorker) {
38+
webWorker.terminate();
39+
webWorker = null;
40+
}
41+
await ensureWorker();
42+
}
43+
3644
export function getDicomSeriesWorkerPool() {
3745
return readDicomSeriesWorkerPool;
3846
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import * as zlib from 'node:zlib';
4+
import { cleanuptotal } from 'wdio-cleanuptotal-service';
5+
import { volViewPage } from '../pageobjects/volview.page';
6+
import { writeManifestToFile } from './utils';
7+
import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf';
8+
9+
const writeBufferToFile = async (data: Buffer, fileName: string) => {
10+
const filePath = path.join(TEMP_DIR, fileName);
11+
await fs.promises.writeFile(filePath, data);
12+
cleanuptotal.addCleanup(async () => {
13+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
14+
});
15+
return filePath;
16+
};
17+
18+
const createNiftiGz = (
19+
dimX: number,
20+
dimY: number,
21+
dimZ: number,
22+
datatype: number,
23+
bitpix: number
24+
) => {
25+
const bytesPerVoxel = bitpix / 8;
26+
const header = Buffer.alloc(352);
27+
28+
header.writeInt32LE(348, 0);
29+
header.writeInt16LE(3, 40);
30+
header.writeInt16LE(dimX, 42);
31+
header.writeInt16LE(dimY, 44);
32+
header.writeInt16LE(dimZ, 46);
33+
header.writeInt16LE(1, 48);
34+
header.writeInt16LE(1, 50);
35+
header.writeInt16LE(1, 52);
36+
header.writeInt16LE(datatype, 70);
37+
header.writeInt16LE(bitpix, 72);
38+
header.writeFloatLE(1, 76);
39+
header.writeFloatLE(1, 80);
40+
header.writeFloatLE(1, 84);
41+
header.writeFloatLE(1, 88);
42+
header.writeFloatLE(352, 108);
43+
header.writeFloatLE(1, 112);
44+
header.writeInt16LE(1, 254);
45+
header.writeFloatLE(1, 280);
46+
header.writeFloatLE(0, 284);
47+
header.writeFloatLE(0, 288);
48+
header.writeFloatLE(0, 292);
49+
header.writeFloatLE(0, 296);
50+
header.writeFloatLE(1, 300);
51+
header.writeFloatLE(0, 304);
52+
header.writeFloatLE(0, 308);
53+
header.writeFloatLE(0, 312);
54+
header.writeFloatLE(0, 316);
55+
header.writeFloatLE(1, 320);
56+
header.writeFloatLE(0, 324);
57+
header.write('n+1\0', 344, 'binary');
58+
59+
const imageData = Buffer.alloc(dimX * dimY * dimZ * bytesPerVoxel);
60+
return zlib.gzipSync(Buffer.concat([header, imageData]), { level: 1 });
61+
};
62+
63+
/**
64+
* Regression test for session restore with large base image + labelmap.
65+
*
66+
* Scenario: Int16 1024×1024×172 base image loaded via URI, with a UInt8
67+
* labelmap of the same dimensions. On the shared ITK-wasm worker, base
68+
* image processing grew the WASM heap to ~1721MB. Loading the labelmap
69+
* on the same worker pushed it past 2GB, causing signed pointer overflow
70+
* in Emscripten's ccall (output pointers > 2^31 wrap negative →
71+
* RangeError: Start offset -N is outside the bounds).
72+
*
73+
* Fix: reset the ITK-wasm worker before deserializing labelmaps to
74+
* prevent heap accumulation.
75+
*/
76+
describe('Session with large URI base and labelmap', function () {
77+
this.timeout(180_000);
78+
79+
it('loads large base image with labelmap without errors', async () => {
80+
const prefix = `session-large-${Date.now()}`;
81+
const baseFileName = `${prefix}-base-i16.nii.gz`;
82+
const labelmapFileName = `${prefix}-labelmap-u8.nii.gz`;
83+
84+
// Int16 1024×1024×172 = 361MB raw
85+
await writeBufferToFile(
86+
createNiftiGz(1024, 1024, 172, 4, 16),
87+
baseFileName
88+
);
89+
// UInt8 labelmap same dimensions = 180MB raw
90+
await writeBufferToFile(
91+
createNiftiGz(1024, 1024, 172, 2, 8),
92+
labelmapFileName
93+
);
94+
95+
const manifest = {
96+
version: '6.1.0',
97+
dataSources: [
98+
{ id: 0, type: 'uri', uri: `/tmp/${baseFileName}` },
99+
{ id: 1, type: 'uri', uri: `/tmp/${labelmapFileName}` },
100+
],
101+
labelMaps: [
102+
{
103+
id: 'seg-1',
104+
dataSourceId: 1,
105+
metadata: {
106+
name: 'Annotation',
107+
parentImage: '0',
108+
segments: {
109+
order: [1],
110+
byValue: {
111+
'1': {
112+
value: 1,
113+
name: 'Label 1',
114+
color: [255, 0, 0, 255],
115+
visible: true,
116+
},
117+
},
118+
},
119+
},
120+
},
121+
],
122+
};
123+
124+
const manifestFileName = `${prefix}-manifest.volview.json`;
125+
await writeManifestToFile(manifest, manifestFileName);
126+
127+
const rangeErrors: string[] = [];
128+
const onLogEntry = (logEntry: { text: string | null }) => {
129+
const text = logEntry.text ?? '';
130+
if (text.includes('RangeError')) {
131+
rangeErrors.push(text);
132+
}
133+
};
134+
browser.on('log.entryAdded', onLogEntry);
135+
136+
try {
137+
await volViewPage.open(`?urls=[tmp/${manifestFileName}]`);
138+
await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6);
139+
140+
expect(rangeErrors).toEqual([]);
141+
142+
const notifications = await volViewPage.getNotificationsCount();
143+
expect(notifications).toEqual(0);
144+
145+
// Verify segment group loaded
146+
const annotationsTab = await $(
147+
'button[data-testid="module-tab-Annotations"]'
148+
);
149+
await annotationsTab.click();
150+
151+
const segmentGroupsTab = await $('button.v-tab*=Segment Groups');
152+
await segmentGroupsTab.waitForClickable();
153+
await segmentGroupsTab.click();
154+
155+
await browser.waitUntil(
156+
async () => {
157+
const segmentGroups = await $$('.segment-group-list .v-list-item');
158+
return (await segmentGroups.length) >= 1;
159+
},
160+
{
161+
timeout: DOWNLOAD_TIMEOUT,
162+
timeoutMsg: 'Segment group not found after session restore',
163+
}
164+
);
165+
} finally {
166+
browser.off('log.entryAdded', onLogEntry);
167+
}
168+
});
169+
});

0 commit comments

Comments
 (0)