What happened
After a Desktop target's Runtime Host connection drops and the candidate reconnects, opening any managed HTML Artifact preview fails for the rest of that target generation with Preview owner is closed — even though the Host itself reconnected successfully.
Expected: after the Host reconnects, previews work again.
Root cause (traced in the source and reproduced against the built module):
ManagedArtifactPreview.closeScope(targetEpoch) permanently records the epoch in retiredScopes (apps/desktop/src/main/managed-artifact-preview.ts). The set is add-only, so a retired scope is rejected by prepare() forever via this.retiredScopes.has(scope).
- On a lost connection the candidate is torn down:
closed is connection.closed.then(() => this.close()), and close() runs disposeClientIpc, which for the owner is the boot teardown that calls managedArtifactPreview.closeScope(scope.targetEpoch) (runtime-host-desktop-candidate.ts, runtime-host-boot.ts).
- A reconnect reuses the same target epoch:
runtime-host-desktop-manager mints the epoch once per target generation (const epoch = randomUUID()), and the reconnect lifecycle installs the replacement candidate without changing it. The replacement re-registers with that epoch, but the epoch is already retired, so every later prepare fails.
Reproduction against the built ManagedArtifactPreview: preparing under an epoch succeeds, closeScope(epoch) — exactly what the candidate teardown runs on a lost connection — retires it, and the next prepare(epoch) rejects with Preview owner is closed; a different epoch still works, so the failure is the permanent retirement, not a general error.
This is a distinct defect from #5341 (deleted previews outliving deletion, and the global quota). The retire semantics and the epoch reuse both originate in #5316.
How to reproduce
Conditions, for a Desktop target whose connection drop actually closes the connection (WSL pipe, local transport, SSH/tls/plaintext websocket, or a libp2p-direct peer whose recovery fails):
- Open a Session and open an HTML Artifact preview (Generated Files → Open in default app, or the ArtifactPreview tool). Confirm it loads.
- Make the Runtime Host connection drop so the candidate closes and the manager reconnects — e.g. restart the Host process, drop the SSH/WSL transport, or let peer recovery exceed its 30 s timeout. The target goes
reconnecting and then ready again, with the same targetEpoch.
- Open an HTML Artifact preview again.
Actual: the preview fails with Preview owner is closed for the remainder of that target generation.
Expected: it opens.
A transient libp2p-direct drop that resumes without closing the connection does not trigger it, because the candidate is not torn down and the scope is not retired.
Minimal reproduction against the built module:
import { ManagedArtifactPreview } from './apps/desktop/dist/main/managed-artifact-preview.js';
const service = new ManagedArtifactPreview();
const epoch = 'target-epoch-1';
await service.prepare(epoch, artifactClient(), 'session-1', 'artifact-1'); // reachable
await service.closeScope(epoch); // candidate teardown on a lost connection
await service.prepare(epoch, artifactClient(), 'session-1', 'artifact-1');
// -> Error: Preview owner is closed
await service.close();
artifactClient() returns a minimal fake with getArtifact / streamArtifact.
Environment
- Maka version or commit: 0.2.0 —
672d82731 (upstream/main; reproduced against the Desktop main built from that tree)
- OS and version: Ubuntu 22.04.5 LTS (Jammy Jellyfish), Linux 5.15.0-186-generic x86_64
- Surface: Desktop
- Node.js version, if running from source: v24.21.0 (npm 11.19.0)
Logs, screenshots, or additional context
Code references:
apps/desktop/src/main/managed-artifact-preview.ts — retiredScopes is add-only; prepare() rejects a retired scope.
apps/desktop/src/main/runtime-host-boot.ts — target teardown calls managedArtifactPreview.closeScope(scope.targetEpoch).
apps/desktop/src/main/runtime-host-desktop-candidate.ts — candidate closed follows connection.closed, and close() runs disposeClientIpc.
apps/desktop/src/main/runtime-host-desktop-manager.ts — the epoch is generated once per target generation and reused across reconnects.
packages/runtime-host/src/client/reconnect-lifecycle.ts — the replacement is installed only after the previous value's closed resolves.
Current coverage gap: the candidate teardown is covered (e.g. runtime-host-desktop-candidate.test.ts "closes managed Artifact previews when the Host connection closes"), but re-registration with the same, already-retired epoch is not.
Possible directions (not prescriptive): release the scope without retiring it on reconnect teardown; clear the retirement when the same epoch re-registers; or mint a new epoch when a candidate is replaced.
Related: #5316 (introduced the scope retirement and the epoch reuse), #5341 / #5394 (same feature area, different defects).
What happened
After a Desktop target's Runtime Host connection drops and the candidate reconnects, opening any managed HTML Artifact preview fails for the rest of that target generation with
Preview owner is closed— even though the Host itself reconnected successfully.Expected: after the Host reconnects, previews work again.
Root cause (traced in the source and reproduced against the built module):
ManagedArtifactPreview.closeScope(targetEpoch)permanently records the epoch inretiredScopes(apps/desktop/src/main/managed-artifact-preview.ts). The set is add-only, so a retired scope is rejected byprepare()forever viathis.retiredScopes.has(scope).closedisconnection.closed.then(() => this.close()), andclose()runsdisposeClientIpc, which for the owner is the boot teardown that callsmanagedArtifactPreview.closeScope(scope.targetEpoch)(runtime-host-desktop-candidate.ts,runtime-host-boot.ts).runtime-host-desktop-managermints the epoch once per target generation (const epoch = randomUUID()), and the reconnect lifecycle installs the replacement candidate without changing it. The replacement re-registers with that epoch, but the epoch is already retired, so every laterpreparefails.Reproduction against the built
ManagedArtifactPreview: preparing under an epoch succeeds,closeScope(epoch)— exactly what the candidate teardown runs on a lost connection — retires it, and the nextprepare(epoch)rejects withPreview owner is closed; a different epoch still works, so the failure is the permanent retirement, not a general error.This is a distinct defect from #5341 (deleted previews outliving deletion, and the global quota). The retire semantics and the epoch reuse both originate in #5316.
How to reproduce
Conditions, for a Desktop target whose connection drop actually closes the connection (WSL pipe, local transport, SSH/tls/plaintext websocket, or a
libp2p-directpeer whose recovery fails):reconnectingand thenreadyagain, with the sametargetEpoch.Actual: the preview fails with
Preview owner is closedfor the remainder of that target generation.Expected: it opens.
A transient
libp2p-directdrop that resumes without closing the connection does not trigger it, because the candidate is not torn down and the scope is not retired.Minimal reproduction against the built module:
artifactClient()returns a minimal fake withgetArtifact/streamArtifact.Environment
672d82731(upstream/main; reproduced against the Desktop main built from that tree)Logs, screenshots, or additional context
Code references:
apps/desktop/src/main/managed-artifact-preview.ts—retiredScopesis add-only;prepare()rejects a retired scope.apps/desktop/src/main/runtime-host-boot.ts— target teardown callsmanagedArtifactPreview.closeScope(scope.targetEpoch).apps/desktop/src/main/runtime-host-desktop-candidate.ts— candidateclosedfollowsconnection.closed, andclose()runsdisposeClientIpc.apps/desktop/src/main/runtime-host-desktop-manager.ts— the epoch is generated once per target generation and reused across reconnects.packages/runtime-host/src/client/reconnect-lifecycle.ts— the replacement is installed only after the previous value'sclosedresolves.Current coverage gap: the candidate teardown is covered (e.g.
runtime-host-desktop-candidate.test.ts"closes managed Artifact previews when the Host connection closes"), but re-registration with the same, already-retired epoch is not.Possible directions (not prescriptive): release the scope without retiring it on reconnect teardown; clear the retirement when the same epoch re-registers; or mint a new epoch when a candidate is replaced.
Related: #5316 (introduced the scope retirement and the epoch reuse), #5341 / #5394 (same feature area, different defects).