Skip to content

bug(desktop): managed artifact previews stay permanently closed after a Host reconnect #5436

Description

@SummerC0zyR0ck

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):

  1. Open a Session and open an HTML Artifact preview (Generated Files → Open in default app, or the ArtifactPreview tool). Confirm it loads.
  2. 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.
  3. 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.tsretiredScopes 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions