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/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/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); + } + }); +});