Skip to content

fix(ui): make multi-GPU viewer previews survive owner termination and queue lifecycle events - #9389

Merged
lstein merged 7 commits into
invoke-ai:mainfrom
lstein:lstein/fix/multigpu-preview-lifecycle
Aug 18, 2026
Merged

fix(ui): make multi-GPU viewer previews survive owner termination and queue lifecycle events#9389
lstein merged 7 commits into
invoke-ai:mainfrom
lstein:lstein/fix/multigpu-preview-lifecycle

Conversation

@lstein

@lstein lstein commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #9263, addressing the two ImageViewer preview issues deferred in @JPPhoto's 2026-07-25 review. They share the same store and handler, so they are fixed together:

1. Terminal-owner fallback (context.tsx): when the session owning the shared $progressEvent/$progressImage globals reached a terminal state, its tile was removed but the globals were cleared — or, for successful completion with auto-switch, parked on the finished session's stale frame via the resolve illusion. Since the tiled view only renders with more than one active session, the remaining session's still-running preview disappeared until its next image event. The globals are now handed to the most recently updated remaining session immediately, for every terminal status and auto-switch mode.

2. Stale progress lifecycle: $progressData was cleaned only by per-item terminal events. It is now also cleared on:

  • queue_cleared — scoped exactly like workflowExecutionCoordinator.onQueueCleared (unscoped and own-user clears apply; foreign scoped clears and the sanitized user_id="redacted" broadcast do not), and the cleared items are marked finished so a trailing invocation_progress event from a worker stopped only by the clear cannot repopulate the preview;
  • socket disconnect (mirroring the app-wide progress stores in setEventListeners);
  • $socket replacement after an auth-token/user change.

Implementation

The store logic is extracted into viewerProgressLifecycle.ts so it can be unit tested without rendering (per the web CLAUDE.md convention of no UI tests); the provider keeps the socket subscriptions and ownership/scope checks. Sessions gain a seq counter so "most recently updated" promotion is exact rather than timestamp-granular.

Tests

16 vitest cases: preview promotion for completed/canceled/failed and both auto-switch modes, multi-survivor promotion order, non-owner termination leaving the preview alone, last-session clear/resolve behavior, repeat-event idempotence, clear scoping (own/unscoped/foreign/sanitized), trailing-progress suppression after a clear, and disconnect reset.

Merge order

Stacked on #9263 — this branch contains the multi-GPU branch's commits. Draft until #9263 merges; will then rebase onto main and mark ready for review.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

🤖 Generated with Claude Code

@github-actions github-actions Bot added api python PRs that change python files invocations PRs that change invocations backend PRs that change backend files services PRs that change app services frontend PRs that change frontend files python-tests PRs that change python tests docs PRs that change docs labels Jul 27, 2026
@lstein lstein added the 6.14.1 label Jul 27, 2026
@lstein lstein moved this to 6.14.1: Bug fixes to 6.14.0 in Invoke - Community Roadmap Jul 28, 2026
@lstein
lstein force-pushed the lstein/fix/multigpu-preview-lifecycle branch from ee4b44b to 34a1d94 Compare July 29, 2026 22:39
@lstein

lstein commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased cleanly onto the current #9263 head (272a2ef2ea); 1691 frontend tests green, tsc + prettier clean.

@lstein
lstein force-pushed the lstein/fix/multigpu-preview-lifecycle branch from 34a1d94 to 6c6fb0d Compare July 30, 2026 01:17
… queue lifecycle events

Two related lifecycle gaps in the image viewer's multi-session preview
state (ImageViewer/context.tsx):

1. Terminal-owner fallback: when the session owning the shared
   $progressEvent/$progressImage globals reached a terminal state, its
   tile was removed but the globals were cleared (or parked on the
   finished session's stale frame via the resolve illusion). Since the
   tiled view only renders with >1 active session, the remaining
   session's still-running preview disappeared until its next image
   event. The globals are now handed to the most recently updated
   remaining session immediately, for every terminal status.

2. Stale lifecycle: $progressData was cleaned only by per-item terminal
   events. It is now also cleared on queue_cleared (scoped like
   workflowExecutionCoordinator.onQueueCleared, and marking the cleared
   items finished so a trailing progress event from a worker stopped
   only by the clear cannot repopulate the preview), on socket
   disconnect, and on $socket replacement (auth-token/user change).

The store logic is extracted into viewerProgressLifecycle.ts so it can
be unit tested without rendering; the provider keeps the socket
subscriptions and ownership/scope checks. 16 new vitest cases cover
promotion across terminal statuses and auto-switch modes, non-owner
termination, clear scoping (own/unscoped/foreign/sanitized), and
disconnect resets.

Follow-up to PR invoke-ai#9263 (JPPhoto review, 2026-07-25).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lstein
lstein force-pushed the lstein/fix/multigpu-preview-lifecycle branch from 6c6fb0d to 4cbbb9d Compare July 30, 2026 01:28
@lstein
lstein marked this pull request as ready for review July 30, 2026 01:41

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge blockers:

  • invokeai/frontend/web/src/features/gallery/components/ImageViewer/viewerProgressLifecycle.ts:77: Queue clear marks only image-tracked items plus current global event. A no-image session can later emit an image and resurrect deleted preview. Test: record item 1 without image, item 2 with image, clear queue, then record item 1 with image; expect rejection.

Other findings/issues to fix:

  • invokeai/frontend/web/src/features/gallery/components/ImageViewer/viewerProgressLifecycle.ts:144: A final-image callback has no session identity. If A promotes B, then B completes before A's image loads, A's callback clears B's retained preview. Test: complete A, complete B, invoke A's delayed load; expect B preview retained.

Suggestions:

  • Consider tracking every session on progress, including image: null, before queue-clear suppression.

  • Consider passing completed item identity through onLoadImage; ignore stale loads from previous sessions.

lstein and others added 3 commits August 16, 2026 22:01
Addresses JPPhoto's review of invoke-ai#9389.

A queue clear marked only sessions that had produced a preview image (plus
the owner of the shared globals) as finished. A session that had reported
progress without an image yet was left unmarked, so its first image event
after the clear resurrected the preview the clear had just dropped. Every
item seen on progress is now tracked, image or not, and marked finished by
the clear.

The final-image load callback carried no session identity, so a late load
from an already-completed session cleared whatever preview was retained at
the time — including a *different* session's pending resolve illusion (A
completes and hands the preview to B, B completes and starts its own
illusion, then A's image finally loads). The callback now takes the loaded
DTO's session_id and ignores loads it can attribute to another tracked
session; unattributable loads (uploads, pre-mount images) still clear, so a
retained preview cannot cover the viewer indefinitely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the previous commit, from an adversarial review of it.

Ignoring a mismatched final-image load removed the only thing that ever
took a retained preview down. The retained session's own image may never
load: the viewer's <Image> reports load failures through onError, not
onLoad, and with concurrent completions auto-switch can settle on another
session's image, so the retained one is never rendered at all. In those
cases the preview — an opaque overlay — covered the viewer until the next
generation. The illusion now carries a timeout armed alongside it, so an
ignored load can only end it early, never keep it up.

Also from the same review:
- A queue clear cancels the items already running before deleting the rows,
  so a worker that claims an item in between gets no terminal event and its
  first progress event lands after the clear. Only the in_progress claim
  event names that item, so the viewer now tracks it.
- Session attributions outlived the state they described: a disconnect or
  socket swap dropped the previews but kept the session ids, so images from
  before the reset were still read as another session's and refused to end
  a later illusion. They are dropped with everything else.
- A completed item that never reported progress armed an illusion with
  nothing retained, leaving the resolving flag on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two of the three disarm cases could not fail: after a load or a reset the
stores are already null, so a leaked timer's clear was indistinguishable
from no timer at all. Assert the timer count instead. Also correct the
setPendingResolve docstring, which claimed more than the code does: a
progress event carrying no image cancels the illusion without replacing
the retained frame, so that frame is bounded by the next preview image
rather than by this timeout — as it is today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lstein

lstein commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @JPPhoto — both findings confirmed against the code and fixed, plus a few things that fell out of adversarially reviewing the fixes themselves. Head is now 80bb03edc0.

1. Queue clear only marked image-tracked items (merge blocker)

Confirmed. $progressData holds only sessions that have produced a preview image, and the shared globals name one item, so a session that had reported progress without an image yet was invisible to both — its first image event after the clear sailed through the finishedQueueItemIds gate.

The lifecycle now tracks unfinishedItemIds: every item seen on progress, image or not, removed on its terminal event and dropped on any reset. The clear marks that set finished. It's a strict superset of the two old sources, so the old loops are gone rather than kept alongside.

Your test is in as written (blocks trailing progress from a session that had not produced a preview image yet).

While tracing this I checked the ordering that makes the hole reachable, and it's narrower than the fix's first framing suggested. clear() runs _cancel_in_progress_matching before deleting rows and emitting queue_cleared (session_queue_sqlite.py:666-706), so anything already running gets its own canceled event first and is finished by the time the clear arrives. The item that genuinely escapes is one a worker claims between the cancel pass and the DELETE: its row is gone, so no terminal event is ever emitted for it, and its first progress event lands seconds later — a preview for a deleted queue item with nothing left to take it down.

Its claim is the only event that names it, so the viewer now also tracks in_progress status changes (dequeue_set_queue_item_status(status="in_progress") emits one). This narrows that race, it does not close it: the claim event is emitted from the worker thread two DB round-trips after the status UPDATE commits (session_queue_sqlite.py:530-546), while queue_cleared comes from the clearing thread, so the claim can still arrive after the clear and re-register the item. Nothing client-side can tell that item apart from a legitimately new one enqueued right after the clear. Closing it properly means the backend emitting a terminal event for an item deleted while claimed — happy to file that as a follow-up if you agree it's worth one.

2. Final-image callback has no session identity

Confirmed, with the sequence you described: A completes and hands the preview to B, B completes and starts its own resolve illusion, then A's image finally loads and clears B's retained frame.

clearProgressOnFinalImageLoad: boolean is now pendingResolveItemId: number | null, and onLoadImage takes the loaded DTO's session_id. Attribution comes from a session→item map fed by progress events (QueueItemStatusChangedEvent has no session_id; InvocationProgressEvent does). A load is ignored only when it positively resolves to a different tracked item.

Both call sites wrap the callback rather than passing it through — <DndImage onLoad={onLoadImage}> would have handed React's event object in as the session id.

One thing worth your attention here. Ignoring loads by identity removes the only thing that ever took a retained preview down, and the retained session's own image is not guaranteed to load at all:

  • DndImage passes fallbackSrc, so Chakra's <Image> routes load failures through onError, never onLoad;
  • auto-switch selection is async per completion (onInvocationComplete awaits the DTO fetch), so with concurrent completions the other session's image can end up selected and the retained session's image is never rendered;
  • and the plainest one: the user clicks any other image during the illusion.

In all three the preview is an opaque bg="base.900" overlay, and the 2-second reveal path is explicitly disabled while isProgressImageResolving is true — so the guard traded a ~200 ms flicker for an overlay stuck until the next generation. The illusion now carries a RESOLVE_TIMEOUT_MS (3 s) safety timeout armed with it; every write to pendingResolveItemId goes through one helper that keeps the timer in sync, so whoever ends the illusion also disarms it. An ignored load can now only end the illusion early, never keep it up.

3 s is a judgment call — long enough that a normal DTO-fetch-plus-full-res-load wins the race, short enough that a stale frame over a deliberately clicked image is brief. Easy to retune if you'd rather bias one way.

Two smaller things from the same pass: the session→item attributions are now dropped in clearAll (they outlived the state they described, so post-reset images read as "another session's" and refused to end a later illusion), and a completed item that never reported progress no longer arms an illusion over nothing, which used to leave $isProgressImageResolving stuck on.

Known residuals (pre-existing, untouched)

A progress event carrying no image replaces $progressEvent but not $progressImage, so the previous session's frame stays up while the next queue item spins up — that's how the overlay persists across a batch today, and the timeout deliberately doesn't cover it, since bounding it would make the overlay flicker mid-batch. The non-owner early-return in onTerminal can strand a frame the same way. Both are main's behavior; I left them and noted the boundary in the code.

Tests

27 lifecycle cases (1816 frontend tests total), tsc/eslint/prettier clean. Each new test was checked by reverting its fix in place and confirming it fails — including two timeout cases that initially passed against a leaked timer because the stores were already null at that point; they now assert the timer count instead.

@JPPhoto
JPPhoto self-requested a review August 17, 2026 03:29

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No merge blockers!

I did find a few corner cases - your call as to whether they get addressed:

  • invokeai/frontend/web/src/features/gallery/components/ImageViewer/viewerProgressLifecycle.ts:150-160: an in_progress event arriving after queue_cleared re-registers a deleted item; later progress resurrects its preview. Test: deliver clear, then claim, then progress; expect progress rejected.

  • invokeai/frontend/web/src/features/gallery/components/ImageViewer/context.tsx:127: sessions with no progress never enter itemIdBySessionId; their final-image load is treated as unknown and clears another session's retained preview. Test: A retains preview, B completes without progress, B image loads; expect A preview retained.

Suggestions:

  • Consider backend clear-generation/tombstone data to reject post-clear claims.
  • Consider recording session_id from claim/terminal events.

@lstein
lstein enabled auto-merge (squash) August 18, 2026 15:54
@lstein
lstein merged commit 5a11826 into invoke-ai:main Aug 18, 2026
17 checks passed
@lstein
lstein deleted the lstein/fix/multigpu-preview-lifecycle branch August 18, 2026 16:03
lstein added a commit to lstein/InvokeAI that referenced this pull request Aug 18, 2026
invoke-ai#9389 landed the multi-session preview lifecycle in main
(viewerProgressLifecycle.ts), which supersedes two pieces of this branch:

- The resolve failsafe added here (RESOLVE_FAILSAFE_MS, 10 s, driven from
  the provider) is replaced by the lifecycle's RESOLVE_TIMEOUT_MS (3 s),
  armed and disarmed together with the pending resolve itself.
- onLoadImage now takes the loaded item's session id so a late load from
  one session cannot cut short another's resolve illusion. The video
  error path passes the video's session_id for the same reason.

Kept from this branch: the shared lastRenderedItemNameRef and
SELECTED_ITEM_REVEAL_DURATION_MS in the viewer context, which the
image/video reveal effects both consume.

JPPhoto's second review finding — a session finishing while another is
still generating tears down the running session's preview — is fixed in
main by the same PR: onTerminal now hands the shared progress atoms to
the freshest remaining session instead of clearing them, and both the
resolve timeout and the attributed load path can only fire while no
successor exists.
lstein added a commit to lstein/InvokeAI that referenced this pull request Aug 19, 2026
Main's invoke-ai#9389 (viewer preview lifecycle) rewrote the same machinery this
branch touched, so this is a semantic reconciliation, not a textual one:

- context.tsx: taken from main wholesale. The lifecycle module supersedes
  this branch's createDeferredClear/getTerminalProgressAction backstop
  (setPendingResolve + RESOLVE_TIMEOUT_MS) and its deadline promotion —
  onTerminal now hands the shared preview to the freshest surviving
  session at terminal time, so the stranded-owner state cannot arise.
  Its disconnect/queue-cleared resets supersede this branch's socket
  lifecycle clearing. progressImageResolution.ts and its tests are
  deleted accordingly.
- CurrentImagePreview.tsx: keeps this branch's thumbnail reveal gating
  and auto-switch reveal suppression, adapted to the identity-gated
  onLoadImage(sessionId) signature — the preload's onReady now passes
  the DTO's session id so the lifecycle can attribute the load.
- context.test.ts replaced by CurrentImagePreview.test.ts: the wiring
  checks for the deleted machinery are covered by main's real lifecycle
  unit tests; the thumbnail-gating, preload-settle, and registry-settle
  checks remain.
- autoSwitchedImages, onInvocationComplete dedupe: unchanged (merged
  cleanly), still unique to this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lstein added a commit to lstein/InvokeAI that referenced this pull request Aug 19, 2026
… + lookup-failure retry

Two of JPPhoto's findings applied to the current head (his third, the
deadline-promotion blocker, was fixed ahead of this round by the merge of
main's invoke-ai#9389: the lifecycle's onTerminal hands the shared preview to the
freshest surviving session on every terminal status, and its test
'keeps promoting through a chain of terminations' pins his exact
promote-B/cancel-B/C-still-active sequence).

1. The auto-switch marker is now scoped to the selection it was recorded
   for, not keyed by image name with a TTL. A redux listener settles the
   marker on every action that moves the gallery selection (matched by
   state change, not action type, so new selection-writing reducers are
   covered automatically). An auto-switch that never renders — because
   the user clicked elsewhere first, even without a rendered-image
   change — is dropped the moment the selection moves on, so it can
   never swallow the user's later click on that image. At most one
   marker exists, and only while its selection stands, so the TTL and
   pending bound are gone.

2. A completion delivery whose DTO lookups all fail no longer poisons
   the dedupe key: the key is dropped so a re-delivery can redo the
   gallery work instead of being turned away as a duplicate of a
   delivery that never landed. Partial failures keep the key — the
   fetched DTOs' board totals and optimistic inserts were already
   dispatched, and a retry would double-count them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lstein added a commit that referenced this pull request Aug 22, 2026
…es or videos (#9475)

* fix(ui): resolve the viewer preview on the thumbnail and stop it sticking

The image viewer holds the last progress preview on screen until the final
image's onLoad fires. Two problems with that.

The reveal was gated on a preload of imageDTO.image_url — the full-resolution
PNG — so on a slow connection the stale latent preview stayed up for the entire
multi-megabyte download. A 256px thumbnail is already generated for every image
and is typically higher resolution than the preview it replaces. Gate on that
instead; DndImage renders it via Chakra's fallbackSrc and swaps the full image
in, in place, once it arrives.

The preload also used the raw URL while DndImage requests useMediaUrl(...),
which appends ?media_cookie_version=N. Different key, so the bytes were fetched
twice (measured: 2 requests mismatched vs 1 matched). Route the preload through
useMediaUrl so it is byte-identical. The reuse is the document's list of
available images, keyed by URL rather than the HTTP cache, so it still holds in
multiuser mode where images are served Cache-Control: private, no-store.

Separately, the viewer's progress atoms are distinct stores from the global ones
in services/events/stores, and only the latter were reset on socket lifecycle
transitions. socket.io has no event replay, so a drop spanning the terminal
queue_item_status_changed loses that event permanently and nothing is left to
clear the opaque overlay covering the finished image — the reported "backgrounded
the tab, came back, only a reload fixes it". Reset the viewer's atoms on
connect/connect_error/disconnect too, matching setEventListeners.

onLoadImage is not a guaranteed callback in any case: Chakra reports a failed
load as onError, useImage only re-runs when src changes, the load can beat the
terminal event, and an all-intermediate item never changes the selection. So the
deferred clear also gets a backstop deadline. The armed flag and its timer live
together in createDeferredClear — as separate state, a path that reset the flag
but leaked the timer let a deadline outlive the generation that armed it and
blank a later one's live preview.

The backstop does not clear while other sessions still have previews, since
nulling $progressImage tears down the whole overlay including multi-GPU tiles,
and the reconnect reset only replaces the map when it holds something, because
connect_error fires once per reconnection attempt.

The terminal-status policy moves to a pure getTerminalProgressAction so the
branchy decision is testable without a socket or a React tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): stop auto-switch flashing the previous image over the next preview

Starting a new generation soon after the previous one finishes made the viewer
flicker: the new previews would appear, then the previous generation's finished
image would cover them for two seconds, then the previews resumed. Waiting
between generations avoided it.

The flash is the "reveal selected image" feature (#9217), which briefly hides
the progress overlay so a mid-generation gallery click is visible. Its only
guard against the auto-switch handoff was $isProgressImageResolving — a timing
guard, and the timing loses: the auto-switch selection is dispatched only after
onInvocationComplete's async DTO fetch, then waits for the thumbnail preload,
and the next generation's first invocation_progress event slots into that
window and resets the flag. By the time the handoff reaches the viewer it is
indistinguishable from a user click, so the reveal fires over the live preview.

Distinguish them by identity instead of timing: auto-switch records the image
name in a small registry at dispatch, and the reveal effect consumes it on the
selection's first render. Consumption happens on every rendered-image change,
not only when the reveal conditions hold, because in the common (unraced) case
the image renders with no progress showing and a leftover entry would suppress
a genuine user selection of the same image later.

Entries also expire after 30 seconds. Recording is unconditional but
consumption requires the image to actually render, so a superseded auto-switch
(two completions within one thumbnail-fetch window — routine with parallel
multi-GPU sessions), a viewer unmounted by comparison mode, or a duplicate
invocation_complete event would otherwise leave an immortal entry whose only
future effect is to swallow a genuine click on that image — the very dead-click
the reveal exists to prevent. The TTL is generous for the dispatch-to-render
handoff it protects; expiring early merely readmits the 2-second flash on a
very slow connection, which is the milder failure.

The suppression branch still lowers $isTemporarilyShowingSelectedImage — the
effect has already cancelled any running reveal's timer by that point, so
returning with the atom raised would wedge the reveal on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): don't strand the viewer under the video progress overlay

During a video render, the progress-preview overlay swallowed every
gallery thumbnail click: the selection changed underneath, but the
opaque overlay stayed on top, so nothing visibly happened until a tab
switch remounted the viewer. Three causes, three fixes:

- CurrentVideoPreview never implemented the temporary reveal that
  CurrentImagePreview got in #9217. Port it: clicking a thumbnail
  mid-render now lifts the overlay for 2 s so the click visibly lands,
  then the live preview returns. An actively-playing video is never
  re-covered (audio would keep running under an opaque overlay with
  unreachable controls); the overlay returns when the player closes.

- The reveal's previous-item tracking was per-component, so any click
  that switched media type (image <-> video swaps the mounted preview
  component) reset it and the reveal was swallowed. The ref now lives
  in the shared ImageViewerContext; the image side is careful not to
  null it while a preload is still pending (adversarial-review finding:
  the mount run would otherwise erase the previous-video fact and kill
  the video->image reveal).

- After completion, the "preview resolves into the final media" clear
  only fired from the final media's load callback. On a slow connection
  that lags far behind completion, and an errored <video> never fires
  it - stranding the overlay permanently. The video error handler now
  clears a pending resolve, and a 10 s failsafe in the context drops
  the illusion rather than strand the overlay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): tile concurrent session previews in the video viewer (multi-GPU)

CurrentImagePreview tiles per-session previews when more than one
render runs concurrently; CurrentVideoPreview only ever rendered the
single shared latest preview, so parallel sessions overwrote each
other's frames in place. Port the ProgressImageTiles branch, mirroring
the image viewer exactly ($activeProgressData is already tracked
per-session in the shared context).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ui): resolve knip warnings

* fix(ui): don't treat an auto-switch to a finished video as a user reveal

CurrentVideoPreview treated any change of the selected video as a
mid-render gallery click, so an auto-switch to a just-finished video hid
the *next* render's live preview behind it for 2 seconds.

The auto-switch selection is dispatched only after onInvocationComplete's
DTO fetch resolves, so a quickly-started next render's first progress
event can land ahead of it and reset $isProgressImageResolving. By the
time the selection reaches the reveal effect it is indistinguishable by
timing from a user click, which is why #9434 fixed the image side by
identity instead: the name being auto-switched to is recorded at dispatch
and consumed on the selection's first render.

Port that to videos: addVideosToGallery records the video name, and the
video reveal effect consumes it on every change of the rendered video
(consuming unconditionally, or a leftover entry would swallow a genuine
later click on the same video). The registry is keyed by gallery item
name, which is polymorphic across images and videos, so
autoSwitchedImages.ts and its test are byte-identical copies of #9434's —
whichever branch merges second adds nothing.

Reported by JPPhoto in review of #9475.

* test(ui): pin the promoted session's preview against a stale resolve timer

Covers JPPhoto's second review scenario on #9475 directly: two sessions
generating, the one owning the shared preview finishes, and the other is
left with no further progress event. Nothing armed by the completion —
neither the resolve timeout nor the finished session's own late image
load — may take the promoted session's preview down.

* fix(ui): address review — deadline ownership handoff + duplicate-completion gallery work

Two fixes from JPPhoto's review:

1. When the resolve deadline fired while other sessions were still active
   (multi-GPU), it only disarmed, leaving the shared progress atoms owned by
   the finished item. The surviving sessions' terminal events then saw a
   foreign owner and ignored them, stranding the opaque overlay on a stale
   preview after the last session ended. The deadline now promotes the most
   recently active session into the shared atoms, so its own terminal event
   clears or re-arms them normally.

2. Duplicate invocation_complete deliveries re-ran the gallery work, which
   double-counted optimistic board totals and re-recorded the auto-switch
   marker after it had been consumed — suppressing a later genuine gallery
   click on that image. The handler now tracks processed invocations itself
   (the shared dedupe map can't be used: the workflow coordinator pre-marks
   first-delivery events for non-active workflow items) and returns before
   any gallery work on a duplicate. The auto-switch registry additionally
   settles on every rendered-image change: a match drops all older entries,
   a miss clears the registry, so no stale entry survives past the next
   render to swallow a genuine click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(ui): drop a comment reference to a symbol this tree doesn't have

autoSwitchedImages.ts was copied from #9434, whose TTL comment points at
that branch's PROGRESS_IMAGE_RESOLVE_TIMEOUT_MS. Main's equivalent is
RESOLVE_TIMEOUT_MS in viewerProgressLifecycle, and the cross-reference
adds nothing here either way.

* fix(ui): close four holes an adversarial review found in the video reveal

- The reveal effect could return with $isTemporarilyShowingSelectedImage
  still true after it had already cleared the running reveal's timer,
  wedging the overlay off for the rest of the render. Reachable on a plain
  mount under StrictMode: the double-invoked effect re-enters with the
  shared ref already holding this video's name and takes the
  previous-name early return. Every path out of the effect now lowers the
  atom; the image side gets the same treatment, where sharing the ref
  makes the shape reachable in principle too.

- A rejected play() routed through the video error handler, which now ends
  a pending resolve illusion. A play rejection is not a load failure — the
  element is intact — so for an unattributable session it could cut short
  an illusion belonging to a different render. The toast path is split
  from the element-error path.

- Playback running to its end left isPlaying true, so the overlay never
  came back and the live preview stayed hidden for the rest of the
  generation. onEnded now drops back to the idle still.

- Concurrent completions (multi-GPU) recorded two auto-switched names but
  only the last selection ever rendered, orphaning the first entry for its
  full 30 s TTL — and that orphan, unlike the ones the TTL was written
  for, never self-heals, so the user's next click on that item was a dead
  click. consume() now drops every entry recorded before the one that
  rendered, since the selection moved on without those ever rendering.

* fix(ui): address review round 2 — selection-scoped auto-switch marker + lookup-failure retry

Two of JPPhoto's findings applied to the current head (his third, the
deadline-promotion blocker, was fixed ahead of this round by the merge of
main's #9389: the lifecycle's onTerminal hands the shared preview to the
freshest surviving session on every terminal status, and its test
'keeps promoting through a chain of terminations' pins his exact
promote-B/cancel-B/C-still-active sequence).

1. The auto-switch marker is now scoped to the selection it was recorded
   for, not keyed by image name with a TTL. A redux listener settles the
   marker on every action that moves the gallery selection (matched by
   state change, not action type, so new selection-writing reducers are
   covered automatically). An auto-switch that never renders — because
   the user clicked elsewhere first, even without a rendered-image
   change — is dropped the moment the selection moves on, so it can
   never swallow the user's later click on that image. At most one
   marker exists, and only while its selection stands, so the TTL and
   pending bound are gone.

2. A completion delivery whose DTO lookups all fail no longer poisons
   the dedupe key: the key is dropped so a re-delivery can redo the
   gallery work instead of being turned away as a duplicate of a
   delivery that never landed. Partial failures keep the key — the
   fetched DTOs' board totals and optimistic inserts were already
   dispatched, and a retry would double-count them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ui): cover the reveal suppression, and narrow the retry to gallery work

An adversarial review of the round-3 changes found the mechanism sound but
its verification hollow: deleting the auto-switch record(), or making the
suppression branch unreachable, left all 1888 tests green. Both mutations
remove the behavior this PR exists to deliver.

- The reveal decision moves into a pure getSelectedItemRevealDecision, unit
  tested branch by branch. It answers 'reveal' or 'hide' and nothing else:
  the caller clears the running reveal's timer before asking, so a path
  that returned without writing the atom would strand the reveal on with
  no timer left to end it. That was reachable in the old shape via the
  previous-name early return.

- onInvocationComplete gains tests that the auto-switched selection is
  actually marked (and is not when auto-switch is off), and that the
  video half of the retry condition is load-bearing, via a mixed
  image+video result.

Two lower-severity findings from the same review:

- A retry re-ran the whole handler, including two side effects that are
  global rather than per-event: the canvas processing flag and
  $lastProgressEvent. A re-delivery arriving after the user started
  another run would stop that run's spinner and blank its progress. The
  dedupe entry now records what is outstanding ('done' vs
  'gallery-retryable') and a retry redoes only the gallery work.

- The retry condition read "DTOs fetched", but two paths return before
  dispatching anything — a first intermediate image, and an all-
  intermediate video result. An event whose surviving output was
  intermediate therefore kept its key with nothing dispatched, so the
  lookup that failed alongside it could never be retried. The counts now
  mean "gallery work dispatched".

Every fix above is pinned by a test that fails without it.

* fix(ui): stop exporting a type nothing imports

knip fails the frontend checks on it: SelectedItemRevealDecision is only
ever the return type of the function declared beside it.

* fix(ui): address review round 2 — selection-scoped marker, completion dedupe, reveal controller

Two merge blockers and four findings from JPPhoto's review.

1. The name-keyed auto-switch registry is replaced with #9434's
   selection-scoped marker, taken verbatim (autoSwitchedImages.ts, the
   settle listener, and its tests are byte-identical between the two
   branches, so whichever merges second does so cleanly). A marker now
   survives only while the selection it was recorded for stands: a redux
   listener settles it on every selection move, so an auto-switch that
   never rendered can no longer swallow a later genuine click on the
   same item — the miss-leaves-marker-pending blocker.

2. Duplicate completion deliveries are deduped at the top of the
   handler (also ported from #9434, which grew the same guard for the
   image path), so a re-delivered video completion no longer re-records
   the marker or re-dispatches the auto-switch selection. A delivery
   whose DTO lookups all fail drops its dedupe key so a re-delivery can
   retry; partial failures keep it, since the fetched DTOs' gallery
   work already went out. addVideosToGallery keeps this branch's
   marker record for the video auto-switch — the one line #9434's copy
   of the file doesn't have.

3. The reveal sequencing moves out of the two preview components'
   effects into a shared controller (selectedItemReveal.ts) whose
   sequences are unit-tested, closing the three holes found in the
   inline version:
   - StrictMode's double-invoked mount effect killed every cross-media
     first reveal in development: the second run found the shared ref
     already holding the name the first run wrote. The controller
     remembers which item its own in-flight reveal is showing and
     re-arms instead of lowering.
   - A click landing inside a resolve window was dead forever: the ref
     advanced past it before the resolving guard ran. The controller
     leaves the ref and the marker untouched while resolving, so the
     click (or auto-switch) keeps its identity and is classified when
     the window ends.
   - Clearing the selection reset the ref to null, making the next
     click read as the viewer's first render. A cleared selection now
     moves the ref to a sentinel that is neither null nor a name, so
     the next selection — including the same item — reveals.

4. The metadata panel is gated on !isPlaying and
   !isTemporarilyShowingSelectedImage explicitly rather than riding on
   !withProgress, which both of those states turn off — it used to land
   exactly on top of the native controls or the just-revealed video.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): carry #9434's per-output retry, and unblock knip

The completion-dedupe port in the previous commit took #9434's state at
the time, which JPPhoto has since found two defects in — both now fixed
there and brought across so the branches stay one implementation:

- A duplicate arriving while the first delivery was still fetching was
  rejected outright, so when that delivery lost its output to a failed
  DTO lookup, the only event that could have retried it was already gone.
  Duplicates now await the in-flight delivery and become its retry;
  several waiters serialize into one.
- The dedupe was event-wide, so a partial failure (plausible for image
  collections) permanently abandoned the output that failed while the
  rest landed. The entry now names the outputs whose lookup failed and a
  retry re-fetches only those, which is what makes recovering a partial
  failure safe from double-counting.

The two handlers are now identical but for the video auto-switch marker
record, which is this branch's.

Also moves the three video-workflow query hooks out of the @knipignore
block in videos.ts: they have call sites on this branch, so knip fails
the frontend checks on the unused tags. Same change #9434 carries.

* fix(ui): repair five defects an adversarial review found in the retry rework

All five are in code this branch shares with #9434, and are fixed there too.

- A result can name the same image twice (an image collection concatenates
  its inputs without deduping). Each occurrence was fetched and counted
  separately, inflating the board total — and because the retry set is
  keyed by name, a retry re-admitted the occurrence that had already
  landed and counted it again. Outputs are now fetched once per distinct
  name.

- An intermediate output returned from the whole pass, abandoning
  siblings that belong in the gallery. That was survivable when the
  dedupe was event-wide; with per-output tracking those siblings are in
  nobody's missing set, so no re-delivery could ever recover them.
  Intermediates are now filtered, as the video path already did.

- A retry re-ran the auto-switch, moving the user's selection (and
  possibly their board) long after they had chosen something else. A
  retry now lands the lost output and nothing more.

- A throw inside the gallery work escaped as an unhandled rejection:
  both call sites discard this handler's promise. It is logged, and the
  outputs whose lookups failed are still recorded as retryable.

- addBoardIdSelectedListener matched galleryViewChanged, which the
  auto-switch dispatches immediately before imageSelected. The probe it
  started woke on that very selection and re-selected the first name in a
  stale list, undoing the auto-switch — and the viewer then revealed that
  wrong image over the live preview, which is exactly the flash the
  marker exists to prevent. An explicit selection now cancels the probe.

Each fix has a test that fails without it, including a real-store test
for the listener.

* fix(ui): don't put the overlay back over a reveal the user already earned

A second adversarial pass, which independently confirmed the five fixes
in the previous commit, found one more and three unpinned invariants.

The controller lowered any in-flight reveal the moment a generation
started resolving. A user clicking mid-render could therefore have their
click covered again 200ms into its two seconds — by an unrelated session
finishing — and, if that session produced no gallery output, stay covered
until the 3s resolve backstop. A reveal already granted for the item on
screen is now re-armed through the resolve window instead of lowered;
one belonging to a different item still lowers.

Newly pinned, each verified by mutation: the timer cancel that every run
depends on (two live timers means the older one cuts the newer reveal
short), the resolve window's lowering path, and that consuming one item's
marker cannot spend another's — reachable whenever the rendered item lags
a just-auto-switched selection.

* fix(ui): repair five defects an adversarial review found in the retry rework

Carried across from #9475, which shares this code — the two handlers stay
one implementation.

- A result can name the same image twice (an image collection
  concatenates its inputs without deduping). Each occurrence was fetched
  and counted separately, inflating the board total; and because the
  retry set is keyed by name, a retry re-admitted the occurrence that had
  already landed. Outputs are fetched once per distinct name.

- An intermediate output returned from the whole pass, abandoning
  siblings that belong in the gallery. Survivable when the dedupe was
  event-wide; with per-output tracking those siblings are in nobody's
  missing set, so nothing could recover them. Intermediates are filtered,
  as the video path already did.

- A retry re-ran the auto-switch, moving the user's selection (and
  possibly their board) long after they had chosen something else.

- A throw inside the gallery work escaped as an unhandled rejection —
  both call sites discard this handler's promise.

- addBoardIdSelectedListener matched galleryViewChanged, which the
  auto-switch dispatches immediately before imageSelected. The probe it
  started woke on that very selection and re-selected the first name in a
  stale list, undoing the auto-switch and revealing the wrong image over
  the live preview. An explicit selection now cancels the probe.

Also pins that consuming one item's marker cannot spend another's.

* fix(ui): cancel the board probe on any selection, not just imageSelected

Two from JPPhoto's third round.

The probe cancellation added last round matched imageSelected only, but
the ordinary gallery paths — thumbnail clicks, shift/ctrl range selects,
keyboard navigation — dispatch selectionChanged. A board or view change
followed by any of those left the probe running, so it still replaced (or
cleared) what the user had just picked, and the viewer then revealed that
item over the live progress. The listener now matches the *change of
active item* rather than a list of actions, which covers every writer,
including any added later.

Second: the reveal controller returned during a resolve window before
recording that the selection had been cleared. Clearing and then
re-selecting the same item inside that window left the ref on that item,
so when the window ended the re-selection read as "nothing changed" and
stayed hidden under the overlay. A clear is now recorded even while
resolving — it has no pending identity to preserve, unlike the landed-
but-unclassified render the deferral exists for.

Both have tests that fail without the fix.

* fix(ui): cancel the board probe on any selection, not just imageSelected

Carried from #9475, where JPPhoto found it: the cancellation added last
round matched imageSelected only, but thumbnail clicks, range selects and
keyboard navigation dispatch selectionChanged. A board or view change
followed by any of those left the probe running, so it still replaced or
cleared what the user had just picked — and the auto-switch path this PR
protects goes through exactly that window. The listener now matches the
change of active item rather than a list of actions.

* fix(ui): cancel the probe on any selection write, and stop losing outputs

Two merge blockers from JPPhoto's fourth round.

The board/view probe compared only the *active* item to decide whether a
selection had landed under it. Narrowing a multi-selection, or re-picking
the item already active, leaves that item unchanged while still being the
user settling what they want — so the probe survived those and replaced
their selection when it woke. It now compares the whole selection: the
state is immutable, so a new array reference is exactly "the selection
was written", and cancelling a probe more often than strictly necessary
costs nothing.

An output lost to a transient DTO lookup failure was only recovered if
the server happened to re-deliver the completion event. Nothing re-emits
one, so in practice the image was simply absent from the gallery and the
board counts until something unrelated refetched them. A delivery that
leaves outputs missing now refetches exactly those names at 1s, 3s and
9s, bounded — an output still missing after that is not coming back from
a retry — with the entry left retryable so a re-delivery can still
recover it for free. Each attempt takes the same path as a re-delivery,
so only missing names are fetched, landed outputs are untouched, and the
global side effects are not re-run; a new pass supersedes the refetch
already queued, so duplicates cannot run two chains.

This is the retry work from the follow-up branch, brought down to where
the blocker was raised. Tests come with it, including the sequences from
the review.

* fix(ui): cancel the probe on any selection write, and stop losing outputs

Carried from #9475, where JPPhoto raised both as merge blockers; this
branch shares the listener and the completion handler.

- The board/view probe compared only the active item, so narrowing a
  multi-selection or re-picking the item already active left it running
  to overwrite the user's selection. It now compares the whole selection.

- An output lost to a transient DTO lookup failure was only recovered if
  the server happened to re-deliver the completion event, which nothing
  makes it do. Missing outputs are now refetched at 1s, 3s and 9s,
  bounded, each attempt taking the same path as a re-delivery so landed
  outputs are untouched and the global side effects are not re-run.

* test(ui): pin the reveal's connection to the overlay atom

The merge resolution replaced #9434's inlined reveal with this branch's controller, and rewrote
the wiring test that went with it. That test carried the only assertion on either branch that the
component writes $isTemporarilyShowingSelectedImage -- it matched the literal hide path the
inlined version had -- and the rewrite dropped it.

Nothing else covers it. selectedItemReveal.test.ts substitutes its own setRevealed, so the
controller tests are structurally incapable of observing the atom, and CurrentVideoPreview's
assertions are all on the read side (withProgress, the metadata gate). Neither component is ever
mounted: this directory has no DOM test environment.

An adversarial review of the merge proved the gap by replacing setRevealed with a no-op in both
previews: all 26 assertions across the two wiring tests still passed, and so did the full suite,
with the reveal completely dead -- a mid-render gallery click doing nothing, which is the bug both
PRs exist to fix. Both tests now fail against that mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): don't let retries outlive their session, or swallow the first click

Two merge blockers and two findings from JPPhoto's fifth round.

Scheduled refetches survived socket and auth teardown. The timers close
over an event from the session that scheduled them but dispatch into
whatever store is current when they fire, so a logout, account switch or
reconnect inside the 13s window would fetch the old session's output and
insert it into the new session's gallery and board caches.
setEventListeners now returns a disposer, and useSocketIO calls it before
disconnecting.

The reveal suppressed the first item the viewer ever rendered, on the
grounds that the viewer opening onto an existing selection is not a
click. But a viewer sitting empty while a generation runs is a state the
user has been shown, and their first click there is a click like any
other — it was landing behind the overlay. An empty selection now records
the cleared-selection sentinel whether or not anything rendered before,
so that click reveals while the open-onto-a-selection render still does
not.

Also, since this is the third round it has come up: the video reveal no
longer starts its two seconds at mount. preload="metadata" and the
near-zero seek do not prove a frame exists, so the reveal could run out
over a black element and then re-cover it. The controller now holds the
claim until the item reports a decoded frame (onLoadedData), bounded by a
1s grace so media that never loads still makes the click land. Readiness
is reported as *which* item has painted rather than a boolean, because a
boolean would be reset from a different effect than the one that reads
it.

And retry success no longer bumps cached board totals: seconds after the
fact another refresh may already have inserted the output, and unlike the
name-list insert those increments do not dedupe. A retry now asks the
server for the affected boards instead.

* fix(ui): end a completion handler's session properly, and restore the socket suite

Restores services/events/setEventListeners.test.ts, which I destroyed in
b874b90: I wrote the file without checking it existed, and its 644
lines of executable socket coverage — workflow invalidation, queue
cancellation, own/foreign routing, cross-user isolation — went with it,
replaced by two source-string checks. The suite is back, and the teardown
it needed is now an executable test in it rather than a grep: the mocked
handler carries a dispose(), and the disposer setEventListeners returns
is asserted to call it.

The blocker behind that test: disposal only cleared queued timers. A DTO
request already in flight came back afterwards, dispatched into whatever
store had replaced the old one, and scheduled fresh retries against it —
so a logout or account switch during a lookup could put one user's output
in the next user's gallery. The handler now knows it has been disposed
and checks after every await: nothing already fetched is dispatched,
nothing new is scheduled, and an event delivered after teardown does
nothing at all.

Also, a duplicate delivery restarted the backoff from one second, so a
stream of duplicates during an outage could keep starting fresh chains —
the bound existed but nothing was bounded by it. The attempt count lives
on the retry state now, and a duplicate resumes the chain where it had
got to.

* fix(ui): close the disposal escapes a self-review found before the next round

A fresh adversarial pass over 042350c, done deliberately before the
reviewer's next round, converged on his known categories.

- An in-flight delivery disposed mid-fetch still ran $lastProgressEvent.set(null)
  after the gallery guards returned. That store is module-global across
  handler sessions, so a stale delivery resolving after a logout or
  account switch blanked the progress event the new session had put
  there. Guarded, and the disposal test now asserts the store is never
  touched.

- The DTO fetch loops kept issuing lookups after disposal — requests
  under the replacement session's credentials, cache writes into its
  store. Both loops now stop.

- Three load-bearing pieces had no test that failed without them, and
  test-insensitivity is where review rounds keep coming from:
  - the video path's post-fetch disposal guard (every disposal test used
    image events; its mutation survived the whole suite);
  - the reveal controller's resolve-window hold for an unpainted claim
    (deleting it silently re-created the swallowed-click failure);
  - the settle-listener registration in store.ts (every listener test
    builds its own store; deleting the registration failed nothing).
  Each now has a test pinned via mutation from a verified cwd — two of
  this session's mutation runs previously "passed" by running against a
  path that did not exist.

* test(ui): mount the reveal wiring in a real DOM instead of grepping for it

JPPhoto's round-7 finding, and a fair one: the preview components' tests
inspected source text, so a wiring or ordering regression could leave the
overlay or the reveal broken with every test green. The package had no
DOM test environment at all.

The wiring the two components shared — one controller per mount, run on
every input change with a cleanup that cancels only the timer, the flag
lowered on unmount — moves into useSelectedItemReveal, and the video's
painted-name readiness into usePaintedItemName beside it. Both components
become a hook call; the hook is mounted under happy-dom (new dev
dependency) with real effect lifecycles and mutation-verified coverage
for exactly the things source text cannot see:

- the image -> video component swap over the shared ref, including the
  outgoing component's timer being cancelled before it can cut the
  incoming reveal short;
- unmount lowering the flag with no timer left to re-raise it;
- StrictMode's double-invoked effects;
- readiness driven by a real <video> element's loadeddata event, and
  reset when the element is swapped for another video.

The remaining source-text assertions shrink to what they are good for:
pinning that the components actually call the tested hook, with the
right readiness expression on each path.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jonathan <34005131+JPPhoto@users.noreply.github.com>
Co-authored-by: JPPhoto <jpollack@jpollackphoto.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.1 api backend PRs that change backend files docs PRs that change docs frontend PRs that change frontend files invocations PRs that change invocations python PRs that change python files python-tests PRs that change python tests services PRs that change app services

Projects

Status: 6.14.1: Bug fixes to 6.14.0

Development

Successfully merging this pull request may close these issues.

2 participants