From 529e2122e616d6248d9d4a2b5d4ccc78a6ecfb2e Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 26 Mar 2026 19:49:56 -0400 Subject: [PATCH 1/5] fix: avoid Array.from OOM when saving large labelmaps VTK.js DataArray.getState() calls Array.from() on typed arrays, which OOMs for images >~180M voxels. Temporarily swap data arrays with empty before getState(), then inject the original TypedArrays into the state. Structured clone handles TypedArrays efficiently for worker transfer. Also surface save errors as user-visible notifications (SaveSession.vue had try/finally but no catch). --- src/components/SaveSession.vue | 6 + src/io/vtk/async.ts | 35 +++++- tests/specs/save-large-labelmap.e2e.ts | 165 +++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 tests/specs/save-large-labelmap.e2e.ts diff --git a/src/components/SaveSession.vue b/src/components/SaveSession.vue index 291933cc9..da2bc91e5 100644 --- a/src/components/SaveSession.vue +++ b/src/components/SaveSession.vue @@ -36,6 +36,7 @@ import { saveAs } from 'file-saver'; import { onKeyDown } from '@vueuse/core'; import { serialize } from '../io/state-file/serialize'; +import { useMessageStore } from '../store/messages'; const DEFAULT_FILENAME = 'session.volview.zip'; @@ -58,6 +59,11 @@ export default defineComponent({ const blob = await serialize(); saveAs(blob, fileName.value); props.close(); + } catch (err) { + const messageStore = useMessageStore(); + messageStore.addError('Failed to save session', { + error: err instanceof Error ? err : new Error(String(err)), + }); } finally { saving.value = false; } diff --git a/src/io/vtk/async.ts b/src/io/vtk/async.ts index 9e21e2db6..0f95fc645 100644 --- a/src/io/vtk/async.ts +++ b/src/io/vtk/async.ts @@ -5,6 +5,39 @@ import vtkDataSet from '@kitware/vtk.js/Common/DataModel/DataSet'; import { vtkObject } from '@kitware/vtk.js/interfaces'; import { StateObject } from './common'; +// VTK.js DataArray.getState() calls Array.from() on typed arrays, +// which OOMs for large images (>~180M voxels). This helper temporarily +// swaps each array's data with empty before getState(), then injects +// the original TypedArrays into the resulting state. Structured clone +// (postMessage) handles TypedArrays efficiently, and vtk() +// reconstruction accepts them in DataArray.extend(). +const getStateWithTypedArrays = (dataSet: vtkDataSet) => { + const pointData = (dataSet as any).getPointData?.(); + const arrays: any[] = pointData?.getArrays?.() ?? []; + + const typedArrays = arrays.map((arr: any) => arr.getData()); + + // Swap to empty so Array.from runs on [] instead of huge TypedArray + arrays.forEach((arr: any) => arr.setData(new Uint8Array(0))); + + let state: any; + try { + state = dataSet.getState(); + } finally { + arrays.forEach((arr: any, i: number) => arr.setData(typedArrays[i])); + } + + // Inject original TypedArrays into the serialized state + state?.pointData?.arrays?.forEach((entry: any, i: number) => { + if (entry?.data) { + entry.data.values = typedArrays[i]; + entry.data.size = typedArrays[i].length; + } + }); + + return state; +}; + interface SuccessReadResult { status: 'success'; obj: StateObject; @@ -52,7 +85,7 @@ export const runAsyncVTKWriter = ); const worker = new PromiseWorker(asyncWorker); const result = (await worker.postMessage({ - obj: dataSet.getState(), + obj: getStateWithTypedArrays(dataSet), writerName, })) as WriteResult; asyncWorker.terminate(); diff --git a/tests/specs/save-large-labelmap.e2e.ts b/tests/specs/save-large-labelmap.e2e.ts new file mode 100644 index 000000000..6bd354da6 --- /dev/null +++ b/tests/specs/save-large-labelmap.e2e.ts @@ -0,0 +1,165 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as zlib from 'node:zlib'; +import { cleanuptotal } from 'wdio-cleanuptotal-service'; +import { volViewPage } from '../pageobjects/volview.page'; +import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf'; +import { writeManifestToFile } from './utils'; + +// 268M voxels — labelmap at this size triggers Array.from OOM +const DIM_X = 1024; +const DIM_Y = 1024; +const DIM_Z = 256; + +const writeBufferToFile = async (data: Buffer, fileName: string) => { + const filePath = path.join(TEMP_DIR, fileName); + await fs.promises.writeFile(filePath, data); + cleanuptotal.addCleanup(async () => { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + }); + return filePath; +}; + +// UInt8 base image — small compressed size, fast to load +const createUint8NiftiGz = () => { + const header = Buffer.alloc(352); + header.writeInt32LE(348, 0); + header.writeInt16LE(3, 40); + header.writeInt16LE(DIM_X, 42); + header.writeInt16LE(DIM_Y, 44); + header.writeInt16LE(DIM_Z, 46); + header.writeInt16LE(1, 48); + header.writeInt16LE(1, 50); + header.writeInt16LE(1, 52); + header.writeInt16LE(2, 70); // datatype: UINT8 + header.writeInt16LE(8, 72); // bitpix + header.writeFloatLE(1, 76); + header.writeFloatLE(1, 80); + header.writeFloatLE(1, 84); + header.writeFloatLE(1, 88); + header.writeFloatLE(352, 108); + header.writeFloatLE(1, 112); + header.writeInt16LE(1, 254); + header.writeFloatLE(1, 280); + header.writeFloatLE(0, 284); + header.writeFloatLE(0, 288); + header.writeFloatLE(0, 292); + header.writeFloatLE(0, 296); + header.writeFloatLE(1, 300); + header.writeFloatLE(0, 304); + header.writeFloatLE(0, 308); + header.writeFloatLE(0, 312); + header.writeFloatLE(0, 316); + header.writeFloatLE(1, 320); + header.writeFloatLE(0, 324); + header.write('n+1\0', 344, 'binary'); + + const imageData = Buffer.alloc(DIM_X * DIM_Y * DIM_Z); + return zlib.gzipSync(Buffer.concat([header, imageData]), { level: 1 }); +}; + +const waitForFileExists = (filePath: string, timeout: number) => + new Promise((resolve, reject) => { + const dir = path.dirname(filePath); + const basename = path.basename(filePath); + + const watcher = fs.watch(dir, (eventType, filename) => { + if (eventType === 'rename' && filename === basename) { + clearTimeout(timerId); + watcher.close(); + resolve(); + } + }); + + const timerId = setTimeout(() => { + watcher.close(); + reject( + new Error(`File ${filePath} not created within ${timeout}ms timeout`) + ); + }, timeout); + + fs.access(filePath, fs.constants.R_OK, (err) => { + if (!err) { + clearTimeout(timerId); + watcher.close(); + resolve(); + } + }); + }); + +describe('Save large labelmap', function () { + this.timeout(180_000); + + it('saves session without error when labelmap exceeds 200M voxels', async () => { + const prefix = `save-large-${Date.now()}`; + const baseFileName = `${prefix}-u8.nii.gz`; + + await writeBufferToFile(createUint8NiftiGz(), baseFileName); + + const manifest = { resources: [{ url: `/tmp/${baseFileName}` }] }; + const manifestFileName = `${prefix}-manifest.json`; + await writeManifestToFile(manifest, manifestFileName); + + await volViewPage.open(`?urls=[tmp/${manifestFileName}]`); + await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6); + + // Activate paint tool — creates a segment group + await volViewPage.activatePaint(); + + // Paint a stroke to allocate the labelmap + const views2D = await volViewPage.getViews2D(); + const canvas = await views2D[0].$('canvas'); + const location = await canvas.getLocation(); + const size = await canvas.getSize(); + const cx = Math.round(location.x + size.width / 2); + const cy = Math.round(location.y + size.height / 2); + + await browser + .action('pointer') + .move({ x: cx, y: cy }) + .down() + .move({ x: cx + 20, y: cy }) + .up() + .perform(); + + const notificationsBefore = await volViewPage.getNotificationsCount(); + + // Save session — before fix, this throws RangeError: Invalid array length + const sessionFileName = await volViewPage.saveSession(); + const downloadedPath = path.join(TEMP_DIR, sessionFileName); + + // Wait for either the file to appear (success) or notification (error) + const saveResult = await Promise.race([ + waitForFileExists(downloadedPath, 90_000).then(() => 'saved' as const), + browser + .waitUntil( + async () => { + const count = await volViewPage.getNotificationsCount(); + return count > notificationsBefore; + }, + { timeout: 90_000, interval: 1000 } + ) + .then(() => 'error' as const), + ]); + + if (saveResult === 'error') { + const errorDetails = await browser.execute(() => { + const app = document.querySelector('#app') as any; + const pinia = app?.__vue_app__?.config?.globalProperties?.$pinia; + if (!pinia) return 'no pinia'; + const store = pinia.state.value.message; + if (!store) return 'no message store'; + return store.msgList + .map((id: string) => { + const msg = store.byID[id]; + return `[${msg.type}] ${msg.title}: ${msg.options?.details?.slice(0, 300)}`; + }) + .join('\n'); + }); + throw new Error(`Save error:\n${errorDetails}`); + } + + const stat = fs.statSync(downloadedPath); + expect(stat.size).toBeGreaterThan(0); + }); +}); From 75fa6c9deee7460544fccef395892548304ead29 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 26 Mar 2026 20:14:33 -0400 Subject: [PATCH 2/5] 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. --- src/io/import/processors/restoreStateFile.ts | 7 + src/io/itk/worker.ts | 8 + tests/specs/session-large-uri-base.e2e.ts | 169 +++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/specs/session-large-uri-base.e2e.ts diff --git a/src/io/import/processors/restoreStateFile.ts b/src/io/import/processors/restoreStateFile.ts index ff332ddfb..c2c8889d9 100644 --- a/src/io/import/processors/restoreStateFile.ts +++ b/src/io/import/processors/restoreStateFile.ts @@ -20,6 +20,7 @@ import { useViewStore } from '@/src/store/views'; import { useViewConfigStore } from '@/src/store/view-configs'; import { migrateManifest } from '@/src/io/state-file/migrations'; import { useMessageStore } from '@/src/store/messages'; +import { resetWorker } from '@/src/io/itk/worker'; type LeafSource = | { type: 'uri'; uri: string; name: string; mime?: string } @@ -136,6 +137,12 @@ export async function completeStateFileRestore( useViewConfigStore().deserializeAll(manifest, stateIDToStoreID); + // Reset the ITK-wasm worker to free accumulated WASM heap from loading + // base images. Without this, loading large labelmaps on the same worker + // can push the heap past 2GB, causing signed pointer overflow in + // Emscripten's ccall (pointers > 2^31 wrap negative). + await resetWorker(); + const segmentGroupIDMap = await useSegmentGroupStore().deserialize( manifest, stateFiles, diff --git a/src/io/itk/worker.ts b/src/io/itk/worker.ts index 8db39de8a..098fb7c5b 100644 --- a/src/io/itk/worker.ts +++ b/src/io/itk/worker.ts @@ -33,6 +33,14 @@ export function getWorker() { return webWorker; } +export async function resetWorker() { + if (webWorker) { + webWorker.terminate(); + webWorker = null; + } + await ensureWorker(); +} + export function getDicomSeriesWorkerPool() { return readDicomSeriesWorkerPool; } diff --git a/tests/specs/session-large-uri-base.e2e.ts b/tests/specs/session-large-uri-base.e2e.ts new file mode 100644 index 000000000..ac08d19a2 --- /dev/null +++ b/tests/specs/session-large-uri-base.e2e.ts @@ -0,0 +1,169 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as zlib from 'node:zlib'; +import { cleanuptotal } from 'wdio-cleanuptotal-service'; +import { volViewPage } from '../pageobjects/volview.page'; +import { writeManifestToFile } from './utils'; +import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf'; + +const writeBufferToFile = async (data: Buffer, fileName: string) => { + const filePath = path.join(TEMP_DIR, fileName); + await fs.promises.writeFile(filePath, data); + cleanuptotal.addCleanup(async () => { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + }); + return filePath; +}; + +const createNiftiGz = ( + dimX: number, + dimY: number, + dimZ: number, + datatype: number, + bitpix: number +) => { + const bytesPerVoxel = bitpix / 8; + const header = Buffer.alloc(352); + + header.writeInt32LE(348, 0); + header.writeInt16LE(3, 40); + header.writeInt16LE(dimX, 42); + header.writeInt16LE(dimY, 44); + header.writeInt16LE(dimZ, 46); + header.writeInt16LE(1, 48); + header.writeInt16LE(1, 50); + header.writeInt16LE(1, 52); + header.writeInt16LE(datatype, 70); + header.writeInt16LE(bitpix, 72); + header.writeFloatLE(1, 76); + header.writeFloatLE(1, 80); + header.writeFloatLE(1, 84); + header.writeFloatLE(1, 88); + header.writeFloatLE(352, 108); + header.writeFloatLE(1, 112); + header.writeInt16LE(1, 254); + header.writeFloatLE(1, 280); + header.writeFloatLE(0, 284); + header.writeFloatLE(0, 288); + header.writeFloatLE(0, 292); + header.writeFloatLE(0, 296); + header.writeFloatLE(1, 300); + header.writeFloatLE(0, 304); + header.writeFloatLE(0, 308); + header.writeFloatLE(0, 312); + header.writeFloatLE(0, 316); + header.writeFloatLE(1, 320); + header.writeFloatLE(0, 324); + header.write('n+1\0', 344, 'binary'); + + const imageData = Buffer.alloc(dimX * dimY * dimZ * bytesPerVoxel); + return zlib.gzipSync(Buffer.concat([header, imageData]), { level: 1 }); +}; + +/** + * Regression test for session restore with large base image + labelmap. + * + * Scenario: Int16 1024×1024×172 base image loaded via URI, with a UInt8 + * labelmap of the same dimensions. On the shared ITK-wasm worker, base + * image processing grew the WASM heap to ~1721MB. Loading the labelmap + * on the same worker pushed it past 2GB, causing signed pointer overflow + * in Emscripten's ccall (output pointers > 2^31 wrap negative → + * RangeError: Start offset -N is outside the bounds). + * + * Fix: reset the ITK-wasm worker before deserializing labelmaps to + * prevent heap accumulation. + */ +describe('Session with large URI base and labelmap', function () { + this.timeout(180_000); + + it('loads large base image with labelmap without errors', async () => { + const prefix = `session-large-${Date.now()}`; + const baseFileName = `${prefix}-base-i16.nii.gz`; + const labelmapFileName = `${prefix}-labelmap-u8.nii.gz`; + + // Int16 1024×1024×172 = 361MB raw + await writeBufferToFile( + createNiftiGz(1024, 1024, 172, 4, 16), + baseFileName + ); + // UInt8 labelmap same dimensions = 180MB raw + await writeBufferToFile( + createNiftiGz(1024, 1024, 172, 2, 8), + labelmapFileName + ); + + const manifest = { + version: '6.1.0', + dataSources: [ + { id: 0, type: 'uri', uri: `/tmp/${baseFileName}` }, + { id: 1, type: 'uri', uri: `/tmp/${labelmapFileName}` }, + ], + labelMaps: [ + { + id: 'seg-1', + dataSourceId: 1, + metadata: { + name: 'Annotation', + parentImage: '0', + segments: { + order: [1], + byValue: { + '1': { + value: 1, + name: 'Label 1', + color: [255, 0, 0, 255], + visible: true, + }, + }, + }, + }, + }, + ], + }; + + const manifestFileName = `${prefix}-manifest.volview.json`; + await writeManifestToFile(manifest, manifestFileName); + + const rangeErrors: string[] = []; + const onLogEntry = (logEntry: { text: string | null }) => { + const text = logEntry.text ?? ''; + if (text.includes('RangeError')) { + rangeErrors.push(text); + } + }; + browser.on('log.entryAdded', onLogEntry); + + try { + await volViewPage.open(`?urls=[tmp/${manifestFileName}]`); + await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6); + + expect(rangeErrors).toEqual([]); + + const notifications = await volViewPage.getNotificationsCount(); + expect(notifications).toEqual(0); + + // Verify segment group loaded + const annotationsTab = await $( + 'button[data-testid="module-tab-Annotations"]' + ); + await annotationsTab.click(); + + const segmentGroupsTab = await $('button.v-tab*=Segment Groups'); + await segmentGroupsTab.waitForClickable(); + await segmentGroupsTab.click(); + + await browser.waitUntil( + async () => { + const segmentGroups = await $$('.segment-group-list .v-list-item'); + return (await segmentGroups.length) >= 1; + }, + { + timeout: DOWNLOAD_TIMEOUT, + timeoutMsg: 'Segment group not found after session restore', + } + ); + } finally { + browser.off('log.entryAdded', onLogEntry); + } + }); +}); From 5935437690a4e14e8418965fc94a6444fbd1332d Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 27 Mar 2026 10:13:34 -0400 Subject: [PATCH 3/5] test(e2e): reproduce WASM RangeError for large session restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/specs/session-large-uri-base.e2e.ts | 148 +++++++++++++--------- 1 file changed, 87 insertions(+), 61 deletions(-) diff --git a/tests/specs/session-large-uri-base.e2e.ts b/tests/specs/session-large-uri-base.e2e.ts index ac08d19a2..65ea022fa 100644 --- a/tests/specs/session-large-uri-base.e2e.ts +++ b/tests/specs/session-large-uri-base.e2e.ts @@ -1,9 +1,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as zlib from 'node:zlib'; +import JSZip from 'jszip'; import { cleanuptotal } from 'wdio-cleanuptotal-service'; import { volViewPage } from '../pageobjects/volview.page'; -import { writeManifestToFile } from './utils'; import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf'; const writeBufferToFile = async (data: Buffer, fileName: string) => { @@ -60,69 +60,86 @@ const createNiftiGz = ( return zlib.gzipSync(Buffer.concat([header, imageData]), { level: 1 }); }; +const createSessionZip = async ( + baseFileName: string, + labelmapNiftiGz: Buffer +) => { + const manifest = { + version: '6.2.0', + dataSources: [ + { + id: 0, + type: 'uri', + uri: `/tmp/${baseFileName}`, + name: baseFileName, + }, + ], + datasets: [{ id: '0', dataSourceId: 0 }], + segmentGroups: [ + { + id: 'seg-1', + path: 'labels/seg-1.nii.gz', + metadata: { + name: 'Annotation', + parentImage: '0', + segments: { + order: [1], + byValue: { + '1': { + value: 1, + name: 'Label 1', + color: [255, 0, 0, 255], + visible: true, + }, + }, + }, + }, + }, + ], + }; + + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify(manifest, null, 2)); + zip.file('labels/seg-1.nii.gz', labelmapNiftiGz); + return zip.generateAsync({ type: 'nodebuffer', compression: 'STORE' }); +}; + /** - * Regression test for session restore with large base image + labelmap. + * Regression test for WASM signed pointer overflow during session restore. + * + * A .volview.zip session with a large Float32 URI-based base image and an + * embedded .nii.gz labelmap. The import pipeline loads the base image + * through the shared ITK-wasm worker, growing the WASM heap past 2GB. + * Then segmentGroupStore.deserialize() calls readImage() for the embedded + * .nii.gz labelmap on the same worker. + * + * The .nii.gz format is critical: .vti labelmaps use a separate JS + * reader and never touch the ITK-wasm worker. * - * Scenario: Int16 1024×1024×172 base image loaded via URI, with a UInt8 - * labelmap of the same dimensions. On the shared ITK-wasm worker, base - * image processing grew the WASM heap to ~1721MB. Loading the labelmap - * on the same worker pushed it past 2GB, causing signed pointer overflow - * in Emscripten's ccall (output pointers > 2^31 wrap negative → - * RangeError: Start offset -N is outside the bounds). + * Without resetting the worker, Emscripten's ccall returns output pointers + * as signed i32. When pointers exceed 2^31 they wrap negative, causing: + * RangeError: Start offset -N is outside the bounds of the buffer * - * Fix: reset the ITK-wasm worker before deserializing labelmaps to - * prevent heap accumulation. + * Fix: resetWorker() before deserializing labelmaps clears the heap. */ -describe('Session with large URI base and labelmap', function () { +describe('Session with large URI base and nii.gz labelmap', function () { this.timeout(180_000); - it('loads large base image with labelmap without errors', async () => { + it('loads session with large Float32 base and embedded nii.gz labelmap', async () => { const prefix = `session-large-${Date.now()}`; - const baseFileName = `${prefix}-base-i16.nii.gz`; - const labelmapFileName = `${prefix}-labelmap-u8.nii.gz`; + const baseFileName = `${prefix}-base-f32.nii.gz`; + const sessionFileName = `${prefix}-session.volview.zip`; - // Int16 1024×1024×172 = 361MB raw + // Float32 1024×1024×256 = 1GB raw — pushes WASM heap past 2GB await writeBufferToFile( - createNiftiGz(1024, 1024, 172, 4, 16), + createNiftiGz(1024, 1024, 256, 16, 32), baseFileName ); - // UInt8 labelmap same dimensions = 180MB raw - await writeBufferToFile( - createNiftiGz(1024, 1024, 172, 2, 8), - labelmapFileName - ); - - const manifest = { - version: '6.1.0', - dataSources: [ - { id: 0, type: 'uri', uri: `/tmp/${baseFileName}` }, - { id: 1, type: 'uri', uri: `/tmp/${labelmapFileName}` }, - ], - labelMaps: [ - { - id: 'seg-1', - dataSourceId: 1, - metadata: { - name: 'Annotation', - parentImage: '0', - segments: { - order: [1], - byValue: { - '1': { - value: 1, - name: 'Label 1', - color: [255, 0, 0, 255], - visible: true, - }, - }, - }, - }, - }, - ], - }; - const manifestFileName = `${prefix}-manifest.volview.json`; - await writeManifestToFile(manifest, manifestFileName); + // UInt8 labelmap same dimensions = 256MB raw, embedded in session ZIP + const labelmapNiftiGz = createNiftiGz(1024, 1024, 256, 2, 8); + const sessionZip = await createSessionZip(baseFileName, labelmapNiftiGz); + await writeBufferToFile(sessionZip, sessionFileName); const rangeErrors: string[] = []; const onLogEntry = (logEntry: { text: string | null }) => { @@ -134,15 +151,10 @@ describe('Session with large URI base and labelmap', function () { browser.on('log.entryAdded', onLogEntry); try { - await volViewPage.open(`?urls=[tmp/${manifestFileName}]`); + await volViewPage.open(`?urls=[tmp/${sessionFileName}]`); await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6); - expect(rangeErrors).toEqual([]); - - const notifications = await volViewPage.getNotificationsCount(); - expect(notifications).toEqual(0); - - // Verify segment group loaded + // Open the segment groups panel so the list renders in the DOM const annotationsTab = await $( 'button[data-testid="module-tab-Annotations"]' ); @@ -152,16 +164,30 @@ describe('Session with large URI base and labelmap', function () { await segmentGroupsTab.waitForClickable(); await segmentGroupsTab.click(); + // Wait for the labelmap readImage to either succeed (segment group + // appears) or fail (RangeError in console OR error notification). + // The deserialization is async and finishes after views render. + const notifsBefore = await volViewPage.getNotificationsCount(); + await browser.waitUntil( async () => { + if (rangeErrors.length > 0) return true; + try { + const notifs = await volViewPage.getNotificationsCount(); + if (notifs > notifsBefore) return true; + } catch { + // badge may not exist yet + } const segmentGroups = await $$('.segment-group-list .v-list-item'); return (await segmentGroups.length) >= 1; }, { - timeout: DOWNLOAD_TIMEOUT, - timeoutMsg: 'Segment group not found after session restore', + timeout: DOWNLOAD_TIMEOUT * 3, + timeoutMsg: 'Labelmap load never completed or errored', } ); + + expect(rangeErrors).toEqual([]); } finally { browser.off('log.entryAdded', onLogEntry); } From 8ff0e2a817bdb3e975081573cbe41babc1cef871 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 5 Apr 2026 12:01:20 -0400 Subject: [PATCH 4/5] fix: use fresh workers for labelmap read/write to avoid WASM heap overflow Replace resetWorker() (which terminated the shared ITK-wasm worker while in-flight operations still referenced it) with per-labelmap temporary workers. Each labelmap read/write gets its own fresh worker with a clean WASM heap, avoiding the 2GB signed pointer overflow in Emscripten without disrupting any concurrent operations on the shared worker. --- src/io/import/processors/restoreStateFile.ts | 7 ------ src/io/itk/worker.ts | 8 ------- src/io/readWriteImage.ts | 14 +++++++---- src/io/state-file/serialize.ts | 2 ++ src/store/segmentGroups.ts | 25 ++++++++++++++++---- 5 files changed, 33 insertions(+), 23 deletions(-) diff --git a/src/io/import/processors/restoreStateFile.ts b/src/io/import/processors/restoreStateFile.ts index c2c8889d9..ff332ddfb 100644 --- a/src/io/import/processors/restoreStateFile.ts +++ b/src/io/import/processors/restoreStateFile.ts @@ -20,7 +20,6 @@ import { useViewStore } from '@/src/store/views'; import { useViewConfigStore } from '@/src/store/view-configs'; import { migrateManifest } from '@/src/io/state-file/migrations'; import { useMessageStore } from '@/src/store/messages'; -import { resetWorker } from '@/src/io/itk/worker'; type LeafSource = | { type: 'uri'; uri: string; name: string; mime?: string } @@ -137,12 +136,6 @@ export async function completeStateFileRestore( useViewConfigStore().deserializeAll(manifest, stateIDToStoreID); - // Reset the ITK-wasm worker to free accumulated WASM heap from loading - // base images. Without this, loading large labelmaps on the same worker - // can push the heap past 2GB, causing signed pointer overflow in - // Emscripten's ccall (pointers > 2^31 wrap negative). - await resetWorker(); - const segmentGroupIDMap = await useSegmentGroupStore().deserialize( manifest, stateFiles, diff --git a/src/io/itk/worker.ts b/src/io/itk/worker.ts index 098fb7c5b..8db39de8a 100644 --- a/src/io/itk/worker.ts +++ b/src/io/itk/worker.ts @@ -33,14 +33,6 @@ export function getWorker() { return webWorker; } -export async function resetWorker() { - if (webWorker) { - webWorker.terminate(); - webWorker = null; - } - await ensureWorker(); -} - export function getDicomSeriesWorkerPool() { return readDicomSeriesWorkerPool; } diff --git a/src/io/readWriteImage.ts b/src/io/readWriteImage.ts index 2cbb25bef..5817faa98 100644 --- a/src/io/readWriteImage.ts +++ b/src/io/readWriteImage.ts @@ -8,15 +8,21 @@ import { import { vtiReader, vtiWriter } from '@/src/io/vtk/async'; import { getWorker } from '@/src/io/itk/worker'; -export const readImage = async (file: File) => { +export const readImage = async (file: File, webWorker?: Worker | null) => { if (file.name.endsWith('.vti')) return (await vtiReader(file)) as vtkImageData; - const { image } = await readImageItk(file, { webWorker: getWorker() }); + const { image } = await readImageItk(file, { + webWorker: webWorker ?? getWorker(), + }); return vtkITKHelper.convertItkToVtkImage(image); }; -export const writeImage = async (format: string, image: vtkImageData) => { +export const writeImage = async ( + format: string, + image: vtkImageData, + webWorker?: Worker | null +) => { if (format === 'vti') { return vtiWriter(image); } @@ -24,7 +30,7 @@ export const writeImage = async (format: string, image: vtkImageData) => { const itkImage = copyImage(vtkITKHelper.convertVtkToItkImage(image)); const result = await writeImageItk(itkImage, `image.${format}`, { - webWorker: getWorker(), + webWorker: webWorker ?? getWorker(), }); return result.serializedImage.data as Uint8Array; }; diff --git a/src/io/state-file/serialize.ts b/src/io/state-file/serialize.ts index b6f8bd774..8f3c0172e 100644 --- a/src/io/state-file/serialize.ts +++ b/src/io/state-file/serialize.ts @@ -60,7 +60,9 @@ export async function serialize() { await datasetStore.serialize(stateFile); viewStore.serialize(stateFile); await useViewConfigStore().serialize(stateFile); + await labelStore.serialize(stateFile); + toolStore.serialize(stateFile); await layersStore.serialize(stateFile); diff --git a/src/store/segmentGroups.ts b/src/store/segmentGroups.ts index 1f23cfa0f..f29269948 100644 --- a/src/store/segmentGroups.ts +++ b/src/store/segmentGroups.ts @@ -10,6 +10,7 @@ import { onImageDeleted } from '@/src/composables/onImageDeleted'; import { normalizeForStore, removeFromArray } from '@/src/utils'; import { SegmentMask } from '@/src/types/segment'; import { DEFAULT_SEGMENT_MASKS, CATEGORICAL_COLORS } from '@/src/config'; +import { createWebWorker } from 'itk-wasm'; import { readImage, writeImage } from '@/src/io/readWriteImage'; import { type DataSelection, @@ -480,12 +481,21 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { state.manifest.segmentGroups = serialized; - // save labelmap images + // save labelmap images — fresh worker per write to avoid heap accumulation await Promise.all( serialized.map(async ({ id, path }) => { const vtkImage = dataIndex[id]; - const serializedImage = await writeImage(saveFormat.value, vtkImage); - zip.file(path, serializedImage); + const worker = await createWebWorker(null); + try { + const serializedImage = await writeImage( + saveFormat.value, + vtkImage, + worker + ); + zip.file(path, serializedImage); + } finally { + worker.terminate(); + } }) ); } @@ -527,7 +537,14 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { const file = stateFiles.find( (entry) => entry.archivePath === normalize(segmentGroup.path!) )?.file; - return { image: await readImage(file!) }; + // Use a fresh worker per labelmap to avoid WASM heap accumulation. + // The shared worker may already have a large heap from base images. + const worker = await createWebWorker(null); + try { + return { image: await readImage(file!, worker) }; + } finally { + worker.terminate(); + } } const labelmapResults = await Promise.all( From 71f7ab37937cbc248fae7721f22c9d37f64892a7 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 5 Apr 2026 12:23:36 -0400 Subject: [PATCH 5/5] fix(e2e): wait for download file to be fully written before checking size The save-large-labelmap test could fail on CI when the file watcher resolved before Chrome finished flushing the download. Poll for non-zero file size before asserting. --- tests/specs/save-large-labelmap.e2e.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/specs/save-large-labelmap.e2e.ts b/tests/specs/save-large-labelmap.e2e.ts index 6bd354da6..f8dfc13ca 100644 --- a/tests/specs/save-large-labelmap.e2e.ts +++ b/tests/specs/save-large-labelmap.e2e.ts @@ -159,6 +159,21 @@ describe('Save large labelmap', function () { throw new Error(`Save error:\n${errorDetails}`); } + // Wait for the file to be fully written (Chrome may create it before flushing) + await browser.waitUntil( + () => { + try { + return fs.statSync(downloadedPath).size > 0; + } catch { + return false; + } + }, + { + timeout: 30_000, + interval: 500, + timeoutMsg: 'Downloaded file remained 0 bytes', + } + ); const stat = fs.statSync(downloadedPath); expect(stat.size).toBeGreaterThan(0); });