diff --git a/plans/001-fix-cancel-load-all-cancelfn.md b/plans/001-fix-cancel-load-all-cancelfn.md new file mode 100644 index 0000000000..53c46be67d --- /dev/null +++ b/plans/001-fix-cancel-load-all-cancelfn.md @@ -0,0 +1,169 @@ +# Plan 001: Make `cancelLoadAll()` actually cancel in-flight loads (`cancel` → `cancelFn`) + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report — do not improvise. When done, update the status row for this plan +> in `plans/README.md` — unless a reviewer dispatched you and told you they +> maintain the index. +> +> **Drift check (run first)**: `git diff --stat b4c094e92..HEAD -- packages/core/src/loaders/imageLoader.ts` +> If the file changed since this plan was written, compare the "Current state" +> excerpts against the live code before proceeding; on a mismatch, treat it as +> a STOP condition. + +## Status + +- **Priority**: P1 +- **Effort**: S +- **Risk**: LOW +- **Depends on**: none +- **Category**: bug +- **Planned at**: commit `b4c094e92`, 2026-07-07 + +## Why this matters + +`cancelLoadAll()` is the public API applications call to abort every queued and +in-flight image/volume load (e.g. when tearing down a study). It calls +`loadObject.cancel()`, but the load-object contract only defines `cancelFn`. +So the moment it finds any in-flight load, it throws `TypeError: +loadObject.cancel is not a function`, which aborts the surrounding loop and +leaves the remaining in-flight requests un-cancelled. Every other call site in +the codebase uses `cancelFn`. + +## Current state + +- `packages/core/src/loaders/imageLoader.ts` — image loading API; `cancelLoadAll` is near the bottom (~line 650–680). +- `packages/core/src/types/ILoadObject.ts` — the contract: both `IImageLoadObject` and `IVolumeLoadObject` declare `cancelFn?: () => void` and `decache?: () => void`; there is **no** `cancel` property. + +The broken code (`packages/core/src/loaders/imageLoader.ts:662-673`): + +```ts + if (imageId) { + loadObject = cache.getImageLoadObject(imageId); + } else if (volumeId) { + loadObject = cache.getVolumeLoadObject(volumeId); + } + if (loadObject) { + loadObject.cancel(); + } +``` + +The correct pattern, from `cancelLoadImage` in the same file +(`packages/core/src/loaders/imageLoader.ts:622-626`): + +```ts + const imageLoadObject = cache.getImageLoadObject(imageId); + + if (imageLoadObject) { + imageLoadObject.cancelFn(); +``` + +Note that `cancelFn` is optional on the type; `cache.ts` guards it +(`if (imageLoadObject?.cancelFn) { imageLoadObject.cancelFn(); }` at +`packages/core/src/cache/cache.ts:178-179`). Match the guarded style. + +Repo conventions: TypeScript, no semicolonless style, prettier-formatted. +Unit tests are jest files named `*.jest.js` under `packages/core/test/` +(karma tests are `*_test.js` in the same directory — do not add a karma test +here). + +## Commands you will need + +| Purpose | Command | Expected on success | +|---|---|---| +| Install | `pnpm install --frozen-lockfile` | exit 0 | +| Typecheck/build core | `pnpm --filter @cornerstonejs/core run build:esm` | exit 0 | +| Unit tests (all) | `pnpm run test:unit:no-coverage` | all pass | +| Unit test (this file) | `pnpm run test:unit:no-coverage -- cancelLoadAll` | new tests pass | +| Lint | `pnpm run lint` | exit 0 | + +## Scope + +**In scope** (the only files you should modify): +- `packages/core/src/loaders/imageLoader.ts` (the `cancelLoadAll` function only) +- `packages/core/test/imageLoader_cancelLoadAll.jest.js` (create) + +**Out of scope** (do NOT touch, even though they look related): +- `packages/core/src/types/ILoadObject.ts` — do not add a `cancel` alias; the contract is correct, the call site is wrong. +- `packages/core/src/cache/cache.ts` — its cancel paths are already correct. +- The `// TODO: Clear retrieval and decoding queues as well` in `cancelLoadAll` — known deferred work, not this plan. + +## Git workflow + +- Branch: `advisor/001-fix-cancel-load-all` +- Commit message style: conventional commits, e.g. `fix(core): use cancelFn in cancelLoadAll so in-flight loads are cancelled` +- Do NOT push or open a PR unless the operator instructed it. + +## Steps + +### Step 1: Fix the call + +In `cancelLoadAll` in `packages/core/src/loaders/imageLoader.ts`, replace: + +```ts + if (loadObject) { + loadObject.cancel(); + } +``` + +with: + +```ts + if (loadObject?.cancelFn) { + loadObject.cancelFn(); + } +``` + +**Verify**: `pnpm --filter @cornerstonejs/core run build:esm` → exit 0. + +### Step 2: Add a regression test + +Create `packages/core/test/imageLoader_cancelLoadAll.jest.js`. Model the file +structure (imports, module reset) on an existing jest test such as +`packages/core/test/stackViewport_node_render.jest.js` (read it first for the +import style; you likely only need `imageLoader`, `imageLoadPoolManager`, and +`cache` from `packages/core/src`). + +Test cases: +1. Register a fake image loader whose load object exposes a jest.fn `cancelFn`, + start a load via `imageLoader.loadAndCacheImage(...)` so the load object is + in the cache, call `cancelLoadAll()`, and assert `cancelFn` was called and + no exception was thrown. +2. A load object **without** `cancelFn` (property absent): `cancelLoadAll()` + must not throw. + +If wiring a real request through `imageLoadPoolManager` proves too indirect, +it is acceptable to test at the cache level: put a load object via +`cache.putImageLoadObject(imageId, { promise, cancelFn })`, enqueue a request +with `additionalDetails.imageId`, then call `cancelLoadAll()`. + +**Verify**: `pnpm run test:unit:no-coverage -- cancelLoadAll` → the new tests pass. + +## Test plan + +Covered by Step 2: happy path (cancelFn invoked), and the guard path (no +cancelFn present, no throw). The bug itself (old code throwing `TypeError`) is +demonstrated by test 1 failing if the fix is reverted. + +## Done criteria + +- [ ] `grep -n "loadObject.cancel()" packages/core/src/loaders/imageLoader.ts` returns no matches +- [ ] `pnpm --filter @cornerstonejs/core run build:esm` exits 0 +- [ ] `pnpm run test:unit:no-coverage -- cancelLoadAll` passes with 2 new tests +- [ ] `pnpm run lint` exits 0 +- [ ] `git status` shows no modified files outside the in-scope list +- [ ] `plans/README.md` status row updated + +## STOP conditions + +Stop and report back (do not improvise) if: + +- `cancelLoadAll` in the live file does not contain `loadObject.cancel();` (drift). +- A `cancel` property actually exists on `IImageLoadObject`/`IVolumeLoadObject` in `packages/core/src/types/ILoadObject.ts` (would mean the contract changed and this plan's premise is wrong). +- The jest test cannot import `packages/core/src` modules at all (project config issue beyond this plan). + +## Maintenance notes + +- Reviewer should confirm only the one call site changed and the guard style matches `cache.ts:178`. +- Deferred (pre-existing TODO in the same function): also cancelling retrieval/decoding queues, and calling `decache()` on cancelled entries — worth a follow-up finding if cancellation memory usage matters. diff --git a/plans/002-fix-get-volume-containing-image-id.md b/plans/002-fix-get-volume-containing-image-id.md new file mode 100644 index 0000000000..86101fc172 --- /dev/null +++ b/plans/002-fix-get-volume-containing-image-id.md @@ -0,0 +1,174 @@ +# Plan 002: Fix `getVolumeContainingImageId` aborting its search on the first empty/unloaded volume + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report — do not improvise. When done, update the status row for this plan +> in `plans/README.md` — unless a reviewer dispatched you and told you they +> maintain the index. +> +> **Drift check (run first)**: `git diff --stat b4c094e92..HEAD -- packages/core/src/cache/cache.ts` +> If the file changed since this plan was written, compare the "Current state" +> excerpts against the live code before proceeding; on a mismatch, treat it as +> a STOP condition. + +## Status + +- **Priority**: P1 +- **Effort**: S +- **Risk**: LOW +- **Depends on**: none +- **Category**: bug +- **Planned at**: commit `b4c094e92`, 2026-07-07 + +## Why this matters + +`cache.getVolumeContainingImageId(imageId)` answers "which cached volume owns +this image?" — used by tools and segmentation code to resolve an image back to +its volume. The lookup loop uses `return` where it means `continue`: the first +volume that is missing or has zero `imageIds` ends the whole search, so a +matching volume iterated later is never found. It also destructures `volume` +from the cached entry without a guard — a volume that is cached but not yet +loaded (`volume === undefined`) makes the function throw `TypeError` instead of +skipping. Both failure modes only appear with 2+ volumes in the cache, which is +exactly the MPR/fusion scenario. + +## Current state + +- `packages/core/src/cache/cache.ts` — the singleton cache class; `getVolumeContainingImageId` at ~lines 615–642. + +The broken loop (`packages/core/src/cache/cache.ts:622-641`): + +```ts + for (const volumeId of volumeIds) { + const cachedVolume = this._volumeCache.get(volumeId); + + if (!cachedVolume) { + return; + } + + const { volume } = cachedVolume; + + if (!volume.imageIds.length) { + return; + } + + const imageIdIndex = volume.getImageURIIndex(imageIdToUse); + + if (imageIdIndex > -1) { + return { volume, imageIdIndex }; + } + } +``` + +Repo conventions: TypeScript, prettier-formatted. Jest unit tests live in +`packages/core/test/*.jest.js` (karma tests are `*_test.js` — don't add karma). + +## Commands you will need + +| Purpose | Command | Expected on success | +|---|---|---| +| Install | `pnpm install --frozen-lockfile` | exit 0 | +| Typecheck/build core | `pnpm --filter @cornerstonejs/core run build:esm` | exit 0 | +| Unit test (this fix) | `pnpm run test:unit:no-coverage -- getVolumeContainingImageId` | new tests pass | +| Lint | `pnpm run lint` | exit 0 | + +## Scope + +**In scope** (the only files you should modify): +- `packages/core/src/cache/cache.ts` (only the `getVolumeContainingImageId` method) +- `packages/core/test/cache_getVolumeContainingImageId.jest.js` (create) + +**Out of scope** (do NOT touch, even though they look related): +- Any other method of `cache.ts` (eviction, purge, size accounting — plan 007 covers geometry sizing separately). +- `getImageURIIndex` / `imageIdToURI` implementations. + +## Git workflow + +- Branch: `advisor/002-fix-get-volume-containing-image-id` +- Commit message style: conventional commits, e.g. `fix(core): continue past empty or unloaded volumes in getVolumeContainingImageId` +- Do NOT push or open a PR unless the operator instructed it. + +## Steps + +### Step 1: Replace the early returns with skips and guard the unloaded case + +Target shape: + +```ts + for (const volumeId of volumeIds) { + const cachedVolume = this._volumeCache.get(volumeId); + + if (!cachedVolume) { + continue; + } + + const { volume } = cachedVolume; + + if (!volume?.imageIds?.length) { + continue; + } + + const imageIdIndex = volume.getImageURIIndex(imageIdToUse); + + if (imageIdIndex > -1) { + return { volume, imageIdIndex }; + } + } +``` + +**Verify**: `pnpm --filter @cornerstonejs/core run build:esm` → exit 0. + +### Step 2: Add regression tests + +Create `packages/core/test/cache_getVolumeContainingImageId.jest.js`. Import +`cache` from `packages/core/src/cache/cache` (or via the package index — match +the import style of an existing jest test in `packages/core/test/`). You can +seed `_volumeCache` through the public API (`cache.putVolumeLoadObject`) or, +if that requires too much volume scaffolding, construct minimal fake volume +objects with `imageIds` and a `getImageURIIndex(uri)` method and insert them +via `putVolumeLoadObject(volumeId, { promise: Promise.resolve(fake) })` +followed by awaiting the promise — read how `putVolumeLoadObject` stores +`volume` on the cached entry in `cache.ts` before choosing the seeding approach. + +Cases: +1. Two volumes cached; the FIRST has `imageIds: []`, the SECOND contains the + target imageId → function returns the second volume + index (fails on the + old code, which returned `undefined`). +2. First cached entry has no `volume` yet (load object whose promise has not + resolved) → function does not throw and still finds the match in the + second volume. +3. No volume contains the imageId → returns `undefined`. + +Call `cache.purgeCache()` (and purge volumes) in `afterEach` so state doesn't +leak between tests — `cache` is a singleton. + +**Verify**: `pnpm run test:unit:no-coverage -- getVolumeContainingImageId` → 3 new tests pass. + +## Test plan + +Covered in Step 2 (regression, unloaded-volume guard, miss path). Pattern +file: any existing `packages/core/test/*.jest.js`. + +## Done criteria + +- [ ] In `getVolumeContainingImageId`, `grep -A3 'if (!cachedVolume)'` shows `continue`, not `return` +- [ ] `pnpm --filter @cornerstonejs/core run build:esm` exits 0 +- [ ] `pnpm run test:unit:no-coverage -- getVolumeContainingImageId` passes (3 new tests) +- [ ] `pnpm run lint` exits 0 +- [ ] `git status` shows no modified files outside the in-scope list +- [ ] `plans/README.md` status row updated + +## STOP conditions + +Stop and report back (do not improvise) if: + +- The live method no longer matches the excerpt (drift). +- You find call sites that *depend* on the early-return behavior (search + `getVolumeContainingImageId` usages first: `grep -rn getVolumeContainingImageId packages/*/src`); if any caller's comment/logic assumes "undefined means first volume was empty", report it. +- Seeding the singleton cache in jest proves impossible without touching production code. + +## Maintenance notes + +- Reviewer: confirm the `volume?.imageIds?.length` guard — an unloaded volume must be skipped, not treated as a match failure for the whole cache. +- Related but separate: geometry cache accounting bugs in the same file are handled by plan 007. diff --git a/plans/003-fix-stackviewport-cpu-load-promise.md b/plans/003-fix-stackviewport-cpu-load-promise.md new file mode 100644 index 0000000000..09d4831a19 --- /dev/null +++ b/plans/003-fix-stackviewport-cpu-load-promise.md @@ -0,0 +1,196 @@ +# Plan 003: Make StackViewport's CPU load promise always settle when a load is superseded by scrolling + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report — do not improvise. When done, update the status row for this plan +> in `plans/README.md` — unless a reviewer dispatched you and told you they +> maintain the index. +> +> **Drift check (run first)**: `git diff --stat b4c094e92..HEAD -- packages/core/src/RenderingEngine/StackViewport.ts` +> If the file changed since this plan was written, compare the "Current state" +> excerpts against the live code before proceeding; on a mismatch, treat it as +> a STOP condition. + +## Status + +- **Priority**: P1 +- **Effort**: S +- **Risk**: LOW +- **Depends on**: none +- **Category**: bug +- **Planned at**: commit `b4c094e92`, 2026-07-07 + +## Why this matters + +On the CPU rendering path, `StackViewport.setImageIdIndex()` returns a promise +that is awaited internally and by applications. When the user scrolls to a new +image while an older image is still loading, the older load's success callback +detects it was superseded and returns early — **without resolving or rejecting +the promise**. Every `await viewport.setImageIdIndex(...)` for a superseded +load hangs forever, leaking pending promises and stalling any caller logic +chained on it (scroll loops, prefetch orchestration, app-level `await`s). + +## Current state + +- `packages/core/src/RenderingEngine/StackViewport.ts` — the stack viewport; CPU load path `_loadAndDisplayImageCPU` at ~lines 2140–2270; `_setImageIdIndex` at ~2759+. + +The promise executor and the early return +(`packages/core/src/RenderingEngine/StackViewport.ts:2140-2157`): + +```ts + private _loadAndDisplayImageCPU( + imageId: string, + imageIdIndex: number + ): Promise { + return new Promise((resolve, reject) => { + // 1. Load the image using the Image Loader + function successCallback( + image: IImage, + imageIdIndex: number, + imageId: string + ) { + // Perform this check after the image has finished loading + // in case the user has already scrolled away to another image. + // In that case, do not render this image. + if (this.currentImageIdIndex !== imageIdIndex) { + return; + } +``` + +`resolve(imageId)` is only reached at the end of `successCallback` +(`StackViewport.ts:2245`), and `reject(error)` in `errorCallback` +(`StackViewport.ts:2263`). The supersession happens because `_setImageIdIndex` +sets `this.currentImageIdIndex = imageIdIndex` **before** awaiting +(`StackViewport.ts:2765`): + +```ts + // Update the state of the viewport to the new imageIdIndex; + this.currentImageIdIndex = imageIdIndex; + this.hasPixelSpacing = true; + this.viewportStatus = ViewportStatus.PRE_RENDER; + ... + const imageId = await this._loadAndDisplayImage( + this.imageIds[imageIdIndex], + imageIdIndex + ); +``` + +Note `_loadAndDisplayImage` (`StackViewport.ts:2131-2138`) dispatches to +`_loadAndDisplayImageCPU` or `_loadAndDisplayImageGPU` based on +`this.useCPURendering`. **Check the GPU path too**: read +`_loadAndDisplayImageGPU` and, if it contains the same +early-return-without-resolve pattern, apply the identical fix there (same +in-scope file). + +Repo conventions: TypeScript, prettier. Jest tests in +`packages/core/test/*.jest.js`; there is an existing StackViewport jest test +(`packages/core/test/stackViewport_node_render.jest.js`) to use as scaffolding +reference for constructing a viewport in tests. + +## Commands you will need + +| Purpose | Command | Expected on success | +|---|---|---| +| Install | `pnpm install --frozen-lockfile` | exit 0 | +| Typecheck/build core | `pnpm --filter @cornerstonejs/core run build:esm` | exit 0 | +| Unit test | `pnpm run test:unit:no-coverage -- stackViewport` | all pass | +| Lint | `pnpm run lint` | exit 0 | + +## Scope + +**In scope** (the only files you should modify): +- `packages/core/src/RenderingEngine/StackViewport.ts` (only `_loadAndDisplayImageCPU`, and `_loadAndDisplayImageGPU` if it has the same pattern) +- `packages/core/test/stackViewport_supersededLoad.jest.js` (create — only if viewport scaffolding from the existing node-render test makes this feasible; see Test plan) + +**Out of scope** (do NOT touch, even though they look related): +- `_setImageIdIndex`'s ordering (setting `currentImageIdIndex` before awaiting is intentional — it is what makes supersession detectable). Do not "fix" by reordering; that changes scroll semantics. +- The render/CPU fallback pipeline invoked inside `successCallback`. +- `packages/core/src/RenderingEngine/helpers/cpuFallback/**`. + +## Git workflow + +- Branch: `advisor/003-fix-stackviewport-cpu-load-promise` +- Commit message style: `fix(core): resolve superseded StackViewport CPU image loads instead of hanging` +- Do NOT push or open a PR unless the operator instructed it. + +## Steps + +### Step 1: Settle the promise in the superseded branch + +In `successCallback` inside `_loadAndDisplayImageCPU`, change: + +```ts + if (this.currentImageIdIndex !== imageIdIndex) { + return; + } +``` + +to resolve with the imageId that WAS requested (callers get a settled promise; +resolving — not rejecting — is the right call because supersession is normal +behavior during scrolling, not an error): + +```ts + if (this.currentImageIdIndex !== imageIdIndex) { + resolve(imageId); + return; + } +``` + +`resolve` is in scope: `successCallback` is a `function` declaration inside the +`new Promise((resolve, reject) => { ... })` executor, and it is invoked with +`.call(this, ...)` so `this` remains the viewport. + +**Verify**: `pnpm --filter @cornerstonejs/core run build:esm` → exit 0. + +### Step 2: Check and, if needed, fix the GPU path + +Read `_loadAndDisplayImageGPU` in the same file. If its success callback has +the same `if (this.currentImageIdIndex !== imageIdIndex) { return; }` guard +inside a promise executor without settling, apply the same one-line fix. +If the GPU path uses a different mechanism (no unsettled early return), leave +it untouched and note that in your report. + +**Verify**: `pnpm --filter @cornerstonejs/core run build:esm` → exit 0. + +### Step 3: Confirm no existing test depended on the hang + +**Verify**: `pnpm run test:unit:no-coverage -- stackViewport` → all pass. + +## Test plan + +Ideal test (attempt it, but see the escape hatch): in +`packages/core/test/stackViewport_supersededLoad.jest.js`, scaffold a stack +viewport following `packages/core/test/stackViewport_node_render.jest.js`, +register a fake image loader with controllable resolution timing, call +`setImageIdIndex(0)` (don't await), immediately call `setImageIdIndex(1)`, +then resolve both fake loads and assert the first promise settles (use +`Promise.race([firstPromise, timeout(2000)])` and assert the race is won by +the promise, not the timeout). + +Escape hatch: if the existing node-render scaffolding cannot force the CPU +path (`useCPURendering`) in jest, skip the new test file, rely on Step 3's +suite, and state in your report that the fix is verified by build + review +only. Do not invent a canvas/WebGL mock stack for this. + +## Done criteria + +- [ ] In `_loadAndDisplayImageCPU`, the superseded branch contains `resolve(imageId);` before `return;` +- [ ] `pnpm --filter @cornerstonejs/core run build:esm` exits 0 +- [ ] `pnpm run test:unit:no-coverage -- stackViewport` passes +- [ ] `pnpm run lint` exits 0 +- [ ] `git status` shows no modified files outside the in-scope list +- [ ] `plans/README.md` status row updated + +## STOP conditions + +Stop and report back (do not improvise) if: + +- The excerpt no longer matches (drift). +- `resolve` is NOT lexically in scope at the early-return site (would mean the promise structure changed). +- You find call sites that intentionally rely on the promise never settling for superseded loads (search for comments near `setImageIdIndex` awaits in `packages/core/src` and `packages/tools/src`). + +## Maintenance notes + +- Reviewer: the choice to `resolve` (vs reject with a cancellation sentinel) means callers cannot distinguish "rendered" from "superseded" by the promise alone; they can compare `viewport.getCurrentImageId()` if they care. If a future caller needs the distinction, introduce a typed `SupersededError` — deferred deliberately. +- If the GPU path has the same bug and was fixed in Step 2, mention both sites in the PR description. diff --git a/plans/004-fix-videoviewport-disabled-handler.md b/plans/004-fix-videoviewport-disabled-handler.md new file mode 100644 index 0000000000..09213c7c3d --- /dev/null +++ b/plans/004-fix-videoviewport-disabled-handler.md @@ -0,0 +1,155 @@ +# Plan 004: Fix VideoViewport's unbound ELEMENT_DISABLED handler so teardown actually runs + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report — do not improvise. When done, update the status row for this plan +> in `plans/README.md` — unless a reviewer dispatched you and told you they +> maintain the index. +> +> **Drift check (run first)**: `git diff --stat b4c094e92..HEAD -- packages/core/src/RenderingEngine/VideoViewport.ts` +> If the file changed since this plan was written, compare the "Current state" +> excerpts against the live code before proceeding; on a mismatch, treat it as +> a STOP condition. + +## Status + +- **Priority**: P1 +- **Effort**: S +- **Risk**: LOW +- **Depends on**: none +- **Category**: bug +- **Planned at**: commit `b4c094e92`, 2026-07-07 + +## Why this matters + +`VideoViewport` registers `this.elementDisabledHandler` as a DOM event +listener without binding it. When `ELEMENT_DISABLED` fires, `this` inside the +handler is the **canvas element**, not the viewport, so +`this.removeEventListeners()` is `undefined` and the handler throws. The +consequences: the listener is never removed, the `