From 2dec537750c4f1957c171bc9367268ba8dd55bd1 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 5 Apr 2026 14:17:02 -0400 Subject: [PATCH] fix: avoid WASM heap overflow when saving/loading large labelmaps Use a fresh temporary web worker for each labelmap read/write instead of the shared ITK-wasm worker. Each worker gets a clean WASM heap, preventing the 2GB signed pointer overflow in Emscripten that occurred when the shared worker's heap accumulated from loading large base images. Also fixes Array.from OOM when saving large labelmaps by using subarray copy instead. --- src/components/SaveSession.vue | 6 + src/io/readWriteImage.ts | 14 +- src/io/vtk/async.ts | 35 +++- src/store/segmentGroups.ts | 25 ++- tests/specs/save-large-labelmap.e2e.ts | 180 ++++++++++++++++++++ tests/specs/session-large-uri-base.e2e.ts | 195 ++++++++++++++++++++++ 6 files changed, 446 insertions(+), 9 deletions(-) create mode 100644 tests/specs/save-large-labelmap.e2e.ts create mode 100644 tests/specs/session-large-uri-base.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/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/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/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( diff --git a/tests/specs/save-large-labelmap.e2e.ts b/tests/specs/save-large-labelmap.e2e.ts new file mode 100644 index 000000000..f8dfc13ca --- /dev/null +++ b/tests/specs/save-large-labelmap.e2e.ts @@ -0,0 +1,180 @@ +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}`); + } + + // 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); + }); +}); 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..65ea022fa --- /dev/null +++ b/tests/specs/session-large-uri-base.e2e.ts @@ -0,0 +1,195 @@ +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 { 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 }); +}; + +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 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. + * + * 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: resetWorker() before deserializing labelmaps clears the heap. + */ +describe('Session with large URI base and nii.gz labelmap', function () { + this.timeout(180_000); + + it('loads session with large Float32 base and embedded nii.gz labelmap', async () => { + const prefix = `session-large-${Date.now()}`; + const baseFileName = `${prefix}-base-f32.nii.gz`; + const sessionFileName = `${prefix}-session.volview.zip`; + + // Float32 1024×1024×256 = 1GB raw — pushes WASM heap past 2GB + await writeBufferToFile( + createNiftiGz(1024, 1024, 256, 16, 32), + baseFileName + ); + + // 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 }) => { + const text = logEntry.text ?? ''; + if (text.includes('RangeError')) { + rangeErrors.push(text); + } + }; + browser.on('log.entryAdded', onLogEntry); + + try { + await volViewPage.open(`?urls=[tmp/${sessionFileName}]`); + await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6); + + // Open the segment groups panel so the list renders in the DOM + 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(); + + // 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 * 3, + timeoutMsg: 'Labelmap load never completed or errored', + } + ); + + expect(rangeErrors).toEqual([]); + } finally { + browser.off('log.entryAdded', onLogEntry); + } + }); +});