Skip to content

Commit 5935437

Browse files
committed
test(e2e): reproduce WASM RangeError for large session restore
Rewrite session-large-uri-base test to actually trigger the signed pointer overflow bug. Uses a ZIP-based session with a Float32 1024×1024×256 base image (1GB raw, pushes WASM heap past 2GB) and an embedded .nii.gz labelmap referenced via `path` in segmentGroups. Key changes: - ZIP session format with `path`-based labelmap triggers readImage() on the shared ITK-wasm worker (the old dataSourceId approach used cached data and never hit the bug path) - Float32 base image grows the WASM heap past 2GB so output pointers exceed 2^31 and wrap negative via Emscripten's signed i32 ccall - .nii.gz labelmap format is critical: .vti uses a separate JS reader that never touches the ITK-wasm worker - Wait for async labelmap deserialization via notification/segment-group detection (it completes after views render) Verified: fails with RangeError when resetWorker() is removed, passes when the fix is active.
1 parent 75fa6c9 commit 5935437

1 file changed

Lines changed: 87 additions & 61 deletions

File tree

tests/specs/session-large-uri-base.e2e.ts

Lines changed: 87 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import * as fs from 'node:fs';
22
import * as path from 'node:path';
33
import * as zlib from 'node:zlib';
4+
import JSZip from 'jszip';
45
import { cleanuptotal } from 'wdio-cleanuptotal-service';
56
import { volViewPage } from '../pageobjects/volview.page';
6-
import { writeManifestToFile } from './utils';
77
import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf';
88

99
const writeBufferToFile = async (data: Buffer, fileName: string) => {
@@ -60,69 +60,86 @@ const createNiftiGz = (
6060
return zlib.gzipSync(Buffer.concat([header, imageData]), { level: 1 });
6161
};
6262

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+
63107
/**
64-
* Regression test for session restore with large base image + labelmap.
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.
65118
*
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).
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
72122
*
73-
* Fix: reset the ITK-wasm worker before deserializing labelmaps to
74-
* prevent heap accumulation.
123+
* Fix: resetWorker() before deserializing labelmaps clears the heap.
75124
*/
76-
describe('Session with large URI base and labelmap', function () {
125+
describe('Session with large URI base and nii.gz labelmap', function () {
77126
this.timeout(180_000);
78127

79-
it('loads large base image with labelmap without errors', async () => {
128+
it('loads session with large Float32 base and embedded nii.gz labelmap', async () => {
80129
const prefix = `session-large-${Date.now()}`;
81-
const baseFileName = `${prefix}-base-i16.nii.gz`;
82-
const labelmapFileName = `${prefix}-labelmap-u8.nii.gz`;
130+
const baseFileName = `${prefix}-base-f32.nii.gz`;
131+
const sessionFileName = `${prefix}-session.volview.zip`;
83132

84-
// Int16 1024×1024×172 = 361MB raw
133+
// Float32 1024×1024×256 = 1GB raw — pushes WASM heap past 2GB
85134
await writeBufferToFile(
86-
createNiftiGz(1024, 1024, 172, 4, 16),
135+
createNiftiGz(1024, 1024, 256, 16, 32),
87136
baseFileName
88137
);
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-
};
123138

124-
const manifestFileName = `${prefix}-manifest.volview.json`;
125-
await writeManifestToFile(manifest, manifestFileName);
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);
126143

127144
const rangeErrors: string[] = [];
128145
const onLogEntry = (logEntry: { text: string | null }) => {
@@ -134,15 +151,10 @@ describe('Session with large URI base and labelmap', function () {
134151
browser.on('log.entryAdded', onLogEntry);
135152

136153
try {
137-
await volViewPage.open(`?urls=[tmp/${manifestFileName}]`);
154+
await volViewPage.open(`?urls=[tmp/${sessionFileName}]`);
138155
await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6);
139156

140-
expect(rangeErrors).toEqual([]);
141-
142-
const notifications = await volViewPage.getNotificationsCount();
143-
expect(notifications).toEqual(0);
144-
145-
// Verify segment group loaded
157+
// Open the segment groups panel so the list renders in the DOM
146158
const annotationsTab = await $(
147159
'button[data-testid="module-tab-Annotations"]'
148160
);
@@ -152,16 +164,30 @@ describe('Session with large URI base and labelmap', function () {
152164
await segmentGroupsTab.waitForClickable();
153165
await segmentGroupsTab.click();
154166

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+
155172
await browser.waitUntil(
156173
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+
}
157181
const segmentGroups = await $$('.segment-group-list .v-list-item');
158182
return (await segmentGroups.length) >= 1;
159183
},
160184
{
161-
timeout: DOWNLOAD_TIMEOUT,
162-
timeoutMsg: 'Segment group not found after session restore',
185+
timeout: DOWNLOAD_TIMEOUT * 3,
186+
timeoutMsg: 'Labelmap load never completed or errored',
163187
}
164188
);
189+
190+
expect(rangeErrors).toEqual([]);
165191
} finally {
166192
browser.off('log.entryAdded', onLogEntry);
167193
}

0 commit comments

Comments
 (0)