Skip to content

Commit d53ed45

Browse files
committed
fix: update itk-wasm to resolve WASM and JSON parse errors
Bump itk-wasm to ^1.0.0-b.197 and @itk-wasm/image-io to 1.6.1. The upstream update fixes a signed-pointer overflow in Emscripten ccall that triggered a RangeError when reading an embedded .nii.gz labelmap from a session zip whose base image had already grown the shared worker's WASM heap past 2GB. Also drop the control-character sanitize patch for itk-wasm's JSON output. The upstream fix in InsightSoftwareConsortium/ITK-Wasm#1457 sanitizes invalid UTF-8 and control characters in metadata at the C++ source, so the JS-side workaround is no longer needed. Verified with the corrupt_dates.dcm fixture from ITK-Wasm#1454 — loads without JSON.parse errors using the plain b.197 build. closes #852
1 parent eda2a47 commit d53ed45

4 files changed

Lines changed: 224 additions & 35 deletions

File tree

package-lock.json

Lines changed: 27 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"@aws-sdk/client-s3": "^3.940.0",
3535
"@eslint/js": "^9.39.1",
3636
"@itk-wasm/dicom": "^7.6.4",
37-
"@itk-wasm/image-io": "1.6.0",
37+
"@itk-wasm/image-io": "1.6.1",
3838
"@itk-wasm/morphological-contour-interpolation": "2.0.0",
3939
"@kitware/vtk.js": "^32.12.1",
4040
"@netlify/edge-functions": "^3.0.2",
@@ -74,7 +74,7 @@
7474
"gl-matrix": "3.4.3",
7575
"globals": "^16.2.0",
7676
"happy-dom": "^20.0.11",
77-
"itk-wasm": "^1.0.0-b.196",
77+
"itk-wasm": "^1.0.0-b.197",
7878
"jszip": "3.10.1",
7979
"lint-staged": "16.2.7",
8080
"mitt": "^3.0.1",

patches/itk-wasm+1.0.0-b.196.patch

Lines changed: 0 additions & 16 deletions
This file was deleted.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import * as zlib from 'node:zlib';
4+
import JSZip from 'jszip';
5+
import { cleanuptotal } from 'wdio-cleanuptotal-service';
6+
import { volViewPage } from '../pageobjects/volview.page';
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+
const createSessionZip = async (
64+
baseFileName: string,
65+
labelmapNiftiGz: Buffer
66+
) => {
67+
const manifest = {
68+
version: '6.2.0',
69+
dataSources: [
70+
{
71+
id: 0,
72+
type: 'uri',
73+
uri: `/tmp/${baseFileName}`,
74+
name: baseFileName,
75+
},
76+
],
77+
datasets: [{ id: '0', dataSourceId: 0 }],
78+
segmentGroups: [
79+
{
80+
id: 'seg-1',
81+
path: 'labels/seg-1.nii.gz',
82+
metadata: {
83+
name: 'Annotation',
84+
parentImage: '0',
85+
segments: {
86+
order: [1],
87+
byValue: {
88+
'1': {
89+
value: 1,
90+
name: 'Label 1',
91+
color: [255, 0, 0, 255],
92+
visible: true,
93+
},
94+
},
95+
},
96+
},
97+
},
98+
],
99+
};
100+
101+
const zip = new JSZip();
102+
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
103+
zip.file('labels/seg-1.nii.gz', labelmapNiftiGz);
104+
return zip.generateAsync({ type: 'nodebuffer', compression: 'STORE' });
105+
};
106+
107+
/**
108+
* Regression test for WASM signed pointer overflow during session restore.
109+
*
110+
* A .volview.zip session with a large Float32 URI-based base image and an
111+
* embedded .nii.gz labelmap. The import pipeline loads the base image
112+
* through the shared ITK-wasm worker, growing the WASM heap past 2GB.
113+
* Then segmentGroupStore.deserialize() calls readImage() for the embedded
114+
* .nii.gz labelmap on the same worker.
115+
*
116+
* The .nii.gz format is critical: .vti labelmaps use a separate JS
117+
* reader and never touch the ITK-wasm worker.
118+
*
119+
* Without resetting the worker, Emscripten's ccall returns output pointers
120+
* as signed i32. When pointers exceed 2^31 they wrap negative, causing:
121+
* RangeError: Start offset -N is outside the bounds of the buffer
122+
*
123+
* Fix: resetWorker() before deserializing labelmaps clears the heap.
124+
*/
125+
describe('Session with large URI base and nii.gz labelmap', function () {
126+
this.timeout(180_000);
127+
128+
it('loads session with large Float32 base and embedded nii.gz labelmap', async () => {
129+
const prefix = `session-large-${Date.now()}`;
130+
const baseFileName = `${prefix}-base-f32.nii.gz`;
131+
const sessionFileName = `${prefix}-session.volview.zip`;
132+
133+
// Float32 1024×1024×256 = 1GB raw — pushes WASM heap past 2GB
134+
await writeBufferToFile(
135+
createNiftiGz(1024, 1024, 256, 16, 32),
136+
baseFileName
137+
);
138+
139+
// UInt8 labelmap same dimensions = 256MB raw, embedded in session ZIP
140+
const labelmapNiftiGz = createNiftiGz(1024, 1024, 256, 2, 8);
141+
const sessionZip = await createSessionZip(baseFileName, labelmapNiftiGz);
142+
await writeBufferToFile(sessionZip, sessionFileName);
143+
144+
const rangeErrors: string[] = [];
145+
const onLogEntry = (logEntry: { text: string | null }) => {
146+
const text = logEntry.text ?? '';
147+
if (text.includes('RangeError')) {
148+
rangeErrors.push(text);
149+
}
150+
};
151+
browser.on('log.entryAdded', onLogEntry);
152+
153+
try {
154+
await volViewPage.open(`?urls=[tmp/${sessionFileName}]`);
155+
await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6);
156+
157+
// Open the segment groups panel so the list renders in the DOM
158+
const annotationsTab = await $(
159+
'button[data-testid="module-tab-Annotations"]'
160+
);
161+
await annotationsTab.click();
162+
163+
const segmentGroupsTab = await $('button.v-tab*=Segment Groups');
164+
await segmentGroupsTab.waitForClickable();
165+
await segmentGroupsTab.click();
166+
167+
// Wait for the labelmap readImage to either succeed (segment group
168+
// appears) or fail (RangeError in console OR error notification).
169+
// The deserialization is async and finishes after views render.
170+
const notifsBefore = await volViewPage.getNotificationsCount();
171+
172+
await browser.waitUntil(
173+
async () => {
174+
if (rangeErrors.length > 0) return true;
175+
try {
176+
const notifs = await volViewPage.getNotificationsCount();
177+
if (notifs > notifsBefore) return true;
178+
} catch {
179+
// badge may not exist yet
180+
}
181+
const segmentGroups = await $$('.segment-group-list .v-list-item');
182+
return (await segmentGroups.length) >= 1;
183+
},
184+
{
185+
timeout: DOWNLOAD_TIMEOUT * 3,
186+
timeoutMsg: 'Labelmap load never completed or errored',
187+
}
188+
);
189+
190+
expect(rangeErrors).toEqual([]);
191+
} finally {
192+
browser.off('log.entryAdded', onLogEntry);
193+
}
194+
});
195+
});

0 commit comments

Comments
 (0)