Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/SaveSession.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions src/io/import/processors/restoreStateFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/io/itk/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
35 changes: 34 additions & 1 deletion src/io/vtk/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
165 changes: 165 additions & 0 deletions tests/specs/save-large-labelmap.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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);
});
});
Loading
Loading