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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 69 additions & 4 deletions apps/desktop/src/main/__tests__/managed-artifact-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ test('isolates leases by origin and rejects credentials for another preview', as
await assert.rejects(fetch(first.url));
assert.equal(await (await fetch(second.url)).text(), 'second');
await assert.rejects(service.prepare('host1', client(), 's1', 'a1'), /closed/);
service.openScope('host1');
assert.equal(await (await fetch((await service.prepare('host1', client(), 's1', 'a1')).url)).text(), html);
await service.revoke('host2', 's1', 'a1');
await assert.rejects(fetch(second.url));
} finally { await service.close(); }
Expand Down Expand Up @@ -136,18 +138,81 @@ test('close and cancellation during a stream cannot publish a live endpoint', as
}
});

test('bounds concurrent preparations before allocating buffers or ports', async () => {
test('bounds previews per session instead of starving another session', async () => {
const service = new ManagedArtifactPreview();
let resume!: () => void;
const gate = new Promise<void>((resolve) => { resume = resolve; });
const source = client();
const slow = { ...source, getArtifact: async (s: string, a: string) => { await gate; return source.getArtifact(s, a); } };
const slow = { ...source, getArtifact: async () => { await gate; return source.getArtifact('s1', 'a1'); } };
const pending = Array.from({ length: 16 }, () => service.prepare('h', slow, 's1', 'a1'));
try {
await assert.rejects(service.prepare('h', slow, 's1', 'a1'), /Too many/);
const other = service.prepare('h', client('other'), 's2', 'a1');
resume();
assert.equal((await Promise.all(pending)).length, 16);
} finally { resume(); await Promise.allSettled(pending); await service.close(); }
assert.equal((await other).reachable, true);
await Promise.all(pending);
} finally {
resume();
await Promise.allSettled(pending);
await service.close();
}
});

test('evicts the oldest lease at the global backstop without denying another session', async () => {
const service = new ManagedArtifactPreview();
try {
const endpoints = [];
for (let index = 0; index < 64; index += 1) {
endpoints.push(await service.prepare('h', client(`preview-${index}`), `s${index}`, 'a1'));
}
const replacement = await service.prepare('h', client('replacement'), 's64', 'a1');
await assert.rejects(fetch(endpoints[0]!.url));
assert.equal(await (await fetch(replacement.url)).text(), 'replacement');
} finally {
await service.close();
}
});

test('releases every preview for a purged session', async () => {
const service = new ManagedArtifactPreview();
try {
const first = await service.prepare('h', client(), 's1', 'a1');
const second = await service.prepare('h', client('second'), 's1', 'a2');
const otherScope = await service.prepare('other-host', client('other scope'), 's1', 'a1');
await service.releaseSession('h', 's1');
await assert.rejects(fetch(first.url));
await assert.rejects(fetch(second.url));
assert.equal(await (await fetch(otherScope.url)).text(), 'other scope');
assert.equal((await service.prepare('h', client(), 's1', 'a1')).reachable, true);
} finally { await service.close(); }
});

test('delete and Session purge cancel previews that are still preparing', async () => {
for (const release of [
(service: ManagedArtifactPreview) => service.revoke('h', 's1', 'a1'),
(service: ManagedArtifactPreview) => service.releaseSession('h', 's1'),
]) {
const service = new ManagedArtifactPreview();
let resume!: () => void;
const gate = new Promise<void>((resolve) => { resume = resolve; });
const source = client();
const preparing = service.prepare('h', {
...source,
getArtifact: async (...args) => {
await gate;
return source.getArtifact(...args);
},
}, 's1', 'a1');
try {
await release(service);
resume();
await assert.rejects(preparing, /closed/);
} finally {
resume();
await Promise.allSettled([preparing]);
await service.close();
}
}
});

test('tool binds to the admitted session and returns endpoint evidence only', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { RuntimeHostReconnectingIpcMain } from '../runtime-host-reconnecting-ipc
import { desktopSessionResourceKey } from '../../shared/runtime-host-identity.js';
import { waitFor as pollFor } from '@maka/core/test-only/async-primitives';
import { canRepairManagedRuntimeHostStartup } from '../runtime-host-startup-recovery.js';
import { ManagedArtifactPreview } from '../managed-artifact-preview.js';

const TEST_HOST_ID = 'a'.repeat(64);
const TEST_TARGET_EPOCH = 'test-target-epoch';
Expand Down Expand Up @@ -499,6 +500,67 @@ test('tears down the whole candidate when the Host connection closes', async ()
assert.equal(host.closeCalls, 1);
});

test('closes old managed Artifact previews and reopens the scope after reconnect', async () => {
const ipc = ipcHarness();
const firstHost = connectionHarness('preview-first');
const preview = new ManagedArtifactPreview();
const bytes = Buffer.from('<!doctype html><title>Preview</title>');
const candidateDeps = {
...deps(ipc),
registerClientIpc: (_client, _ipc, _controls, _target, scope) => {
preview.openScope(scope.targetEpoch);
return () => preview.closeScope(scope.targetEpoch);
},
} satisfies DesktopRuntimeHostCandidateDeps;
const firstCandidate = await createDesktopRuntimeHostCandidate(firstHost.connection, candidateDeps);
let secondCandidate: Awaited<ReturnType<typeof createDesktopRuntimeHostCandidate>> | undefined;
const source = {
getArtifact: async () => ({
id: 'artifact-1',
sessionId: 'session-1',
turnId: 'turn-1',
createdAt: 0,
name: 'preview.html',
kind: 'html' as const,
sizeBytes: bytes.length,
source: 'tool_result' as const,
}),
streamArtifact: async (_sessionId: string, _artifactId: string, write: (chunk: Uint8Array) => Promise<void>) => {
await write(bytes);
return bytes.length;
},
};

try {
const endpoint = await preview.prepare(
TEST_TARGET_EPOCH,
source,
'session-1',
'artifact-1',
);
assert.equal(await (await fetch(endpoint.url)).text(), bytes.toString());

firstHost.disconnect();
await firstCandidate.closed;

await assert.rejects(fetch(endpoint.url));

const secondHost = connectionHarness('preview-second');
secondCandidate = await createDesktopRuntimeHostCandidate(secondHost.connection, candidateDeps);
const replacement = await preview.prepare(
TEST_TARGET_EPOCH,
source,
'session-1',
'artifact-1',
);
assert.equal(await (await fetch(replacement.url)).text(), bytes.toString());
} finally {
await firstCandidate.close();
await secondCandidate?.close();
await preview.close();
}
});

test('preserves supported IPC when the connection closes before candidate startup returns', { timeout: 5_000 }, async (t) => {
const ipc = ipcHarness();
const router = new RuntimeHostReconnectingIpcMain(ipc);
Expand Down
27 changes: 26 additions & 1 deletion apps/desktop/src/main/managed-artifact-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import type { DesktopRuntimeHostClient } from './runtime-host-client.js';

export const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
const MAX_PREVIEWS = 16;
// Per-Session admission alone cannot bound the Desktop, so one global backstop
// remains. It evicts the oldest lease instead of rejecting the newest: a global
// rejection is what let one Session starve every other for a full TTL.
const MAX_TOTAL_PREVIEWS = 64;
const PREVIEW_TTL_MS = 30 * 60 * 1000;
const READ_DEADLINE_MS = 30_000;

Expand Down Expand Up @@ -52,6 +56,10 @@ export class ManagedArtifactPreview {

constructor(private readonly ttlMs = PREVIEW_TTL_MS) {}

openScope(scope: string): void {
this.retiredScopes.delete(scope);
}

async releaseUrl(url: string): Promise<void> {
const lease = [...this.leases].find((entry) => entry.url === url);
if (lease) await this.release(lease);
Expand All @@ -68,7 +76,16 @@ export class ManagedArtifactPreview {
throw new Error('Invalid Artifact identity');
}
if (this.closed || this.retiredScopes.has(scope)) throw new Error('Preview owner is closed');
if (this.leases.size >= MAX_PREVIEWS) throw new Error('Too many active previews; wait for expiry');
const sessionLeases = [...this.leases].filter(
(lease) => lease.scope === scope && lease.sessionId === sessionId,
);
if (sessionLeases.length >= MAX_PREVIEWS) {
throw new Error('Too many active previews; wait for expiry');
}
if (this.leases.size >= MAX_TOTAL_PREVIEWS) {
const oldest = this.leases.values().next().value as Lease | undefined;
if (oldest) void this.release(oldest);
}
signal?.throwIfAborted();
// Reserve before asynchronous reads, so concurrent preparations cannot exceed the bound.
const lease: Lease = { scope, sessionId, artifactId, server: createServer() };
Expand Down Expand Up @@ -156,6 +173,14 @@ export class ManagedArtifactPreview {
await Promise.all([...this.leases].filter((lease) => lease.scope === scope && lease.sessionId === sessionId && lease.artifactId === artifactId).map((lease) => this.release(lease)));
}

async releaseSession(scope: string, sessionId: string): Promise<void> {
await Promise.all(
[...this.leases]
.filter((lease) => lease.scope === scope && lease.sessionId === sessionId)
.map((lease) => this.release(lease)),
);
}

async closeScope(scope: string): Promise<void> {
this.retiredScopes.add(scope);
await Promise.all([...this.leases].filter((lease) => lease.scope === scope).map((lease) => this.release(lease)));
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export function registerRuntimeHostArtifactsIpc(
"artifacts:delete",
async (_event, sessionId: string, artifactId: string) => {
const result = await deps.client.deleteArtifact(sessionId, artifactId);
// Keep the direct revoke: the Host also publishes artifact.changed, but
// stopping the bytes here must not depend on feed delivery to this Client.
await deps.preview?.service.revoke(deps.preview.scope, sessionId, artifactId);
return result;
},
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1572,6 +1572,13 @@ function registerHostClientIpc(
const unsubscribeSessionCatalogChanges = client.subscribeSessionCatalogChanges(
({ sessionId }) => emitTargetSessionsChanged("updated", sessionId),
);
const unsubscribeArtifactChanges = client.subscribeArtifactChanges((frame) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 — A deletion missed while reconnecting leaves the deleted preview live for the rest of its TTL. artifact.changed is a transient frame with no revision or replay, and RuntimeHostReconnectingConnection only rebinds this listener to the replacement connection. The existing preview scope is not closed when availability is lost. A reachable sequence is: Desktop prepares an HTML preview; its remote/SSH/WSL Host connection drops; the still-running Host deletes the Artifact through another Client, Deep Research rollback, or Session purge; the invalidation is emitted while no Desktop subscription exists; Desktop reconnects and receives only future frames. The local preview server therefore keeps serving the deleted snapshot for up to 30 minutes. The new reconnect test itself establishes the non-replay behavior by forwarding only frames emitted by the replacement connection, so the PR's deletion guarantee does not hold across a connection gap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the careful review, @me2seeks — the concern is fair, and it sent us back through the Desktop connection model in detail. Here is what we found, and where we would value your guidance.

On the Desktop, the reconnecting path you described does not appear to exist. RuntimeHostReconnectingConnection is constructed only by the CLI/TUI clients; no Desktop (main-process) code path builds one. So the "listener is rebound to the replacement connection while the old preview scope stays open" mechanism does not apply to the Desktop.

For the connections the Desktop does use:

  • libp2p-direct peer: the Host reuses the same connection session across a resume — peer-listener.ts handles the resume branch and returns without calling accept again — so the change-feed subscription is never dropped, and outbound bytes are buffered and replayed (2 MiB window, 30 s recovery). We already have tests for both the replay (resumable-peer-stream: "one-way blackhole triggers automatic recovery and preserves the pending read", "real TCP replacement preserves one Host dispatcher…") and the session reuse (peer-listener: "…resume spends no slot").
  • Non-resumable transports (WSL pipe, local transport, SSH/tls/plaintext websockets): a drop closes the RuntimeHostConnection; the candidate tears down and the existing teardown calls ManagedArtifactPreview.closeScope, releasing every lease for the scope. Covered by runtime-host-desktop-candidate: "tears down the whole candidate when the Host connection closes".
  • If peer recovery exceeds 30 s or the send window, the stream closes and the connection closes too — the same closeScope path.

So we could not construct a Desktop sequence where a deletion is published while no subscription exists and the connection stays open. We removed the availability-based hook we had tried, because it only applies to reconnecting connections and would never fire here.

We may well be missing a path. If you have a specific one in mind — a transport, a mount, or a client we overlooked — we would be glad to hook the release to whatever signal actually fires there. Could you point us at it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 — A normal Desktop reconnect permanently disables artifact previews for that target.

The concrete path is the candidate cleanup, not RuntimeHostReconnectingConnection: DesktopRuntimeHostCandidateImpl calls disposeClientIpc when connection.closed settles; registerHostClientIpc then calls managedArtifactPreview.closeScope(scope.targetEpoch) (runtime-host-boot.ts:1913). closeScope adds that epoch to retiredScopes (managed-artifact-preview.ts:74 rejects every retired scope). However, createDesktopRuntimeHostCandidate derives scope.targetEpoch from ipcMain.epoch (runtime-host-desktop-candidate.ts:549), and the Desktop manager creates every replacement candidate with the same target.epoch (runtime-host-desktop-manager.ts:1135). The replacement therefore reuses an already-retired scope.

I reproduced this on 9bd1819f6142d477726f8a9b5760aa5325df00f9: prepare('same-epoch') → closeScope('same-epoch') → prepare('same-epoch') returns Error: Preview owner is closed. After a normal WSL/SSH/local reconnect, existing preview leases are released but every later artifact preview for that target stays unavailable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for tracing the concrete path — you are right. closeScope(targetEpoch) released the existing leases but also permanently retired an epoch that Desktop reuses across replacement candidates.

I fixed this by reopening the scope only after the replacement candidate successfully registers. Teardown still retires the scope and closes all old leases, so requests cannot create previews during the reconnect gap.

The regression test now verifies the complete lifecycle: the old preview URL becomes unreachable after disconnect, and a replacement candidate using the same targetEpoch can create and serve a new preview.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hi @me2seeks — the P1 you found is fixed on the current head 6c444a5c.
Teardown still retires the scope and releases the old leases, and the replacement candidate reopens the scope only after it registers, so a normal reconnect can prepare previews again on the same target epoch.
The regression test now covers the full lifecycle: the old URL fails after the disconnect, and a replacement candidate on the same targetEpoch can prepare and serve a new preview.

Could you take another look at the current head when you have a moment? The required approval is the only thing left on my side.

if (frame.reason === 'deleted') {
void managedArtifactPreview.revoke(scope.targetEpoch, frame.sessionId, frame.artifactId);
} else {
void managedArtifactPreview.releaseSession(scope.targetEpoch, frame.sessionId);
}
});
const unsubscribeProjectCatalogChanges = client.subscribeProjectCatalogChanges(() => {
sendToRenderer("projects:changed");
});
Expand Down Expand Up @@ -1838,12 +1845,14 @@ function registerHostClientIpc(
});
registerOnboardingIpc({ onboardingService, ipcMain: scopedIpc });
registerTaskSubmissionReadinessIpc(taskSubmissionReadinessService, scopedIpc);
managedArtifactPreview.openScope(scope.targetEpoch);
return async () => {
clientPluginTransport.release(client);
unsubscribeConfigurationChanges();
await managedArtifactPreview.closeScope(scope.targetEpoch);
unsubscribeConnectionCatalogChanges();
unsubscribeSessionCatalogChanges();
unsubscribeArtifactChanges();
unsubscribeProjectCatalogChanges();
unsubscribeScheduledTaskChanges();
runtimePolicyTargets.delete(target);
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import {
type QueueEntryUpdateInput,
type QueueMutationResult,
SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES,
type ArtifactChangedFrame,
type SessionCatalogChangedFrame,
type ScheduledTaskChangedFrame,
type SessionCatalogItem,
Expand Down Expand Up @@ -387,6 +388,11 @@ export class DesktopRuntimeHostClient {
return this.connection.subscribeConfigurationChanges(listener);
}

subscribeArtifactChanges(listener: (frame: ArtifactChangedFrame) => void): () => void {
this.#assertOpen();
return this.connection.subscribeArtifactChanges(listener);
}

subscribeConnectionCatalogChanges(listener: (revision: number) => void): () => void {
this.#assertOpen();
return this.connection.subscribeConnectionCatalogChanges(listener);
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/acp-stdio-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,7 @@ describe('Maka ACP stdio server', () => {
return subscription;
},
subscribeConfigurationChanges: () => () => undefined,
subscribeArtifactChanges: () => () => undefined,
subscribeConnectionCatalogChanges: () => () => undefined,
subscribeProjectCatalogChanges: () => () => undefined,
subscribeSessionCatalogChanges: () => () => undefined,
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/__tests__/runtime-host-cli-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ test('CLI Runtime Host bootstrap launches the execution composition', async () =
closed: new Promise<void>(() => {}),
status: async () => ({ state: 'ready' }),
subscribeConfigurationChanges: () => () => {},
subscribeArtifactChanges: () => () => {},
subscribeConnectionCatalogChanges: () => () => {},
subscribeProjectCatalogChanges: () => () => {},
subscribeSessionCatalogChanges: () => () => {},
Expand Down Expand Up @@ -109,6 +110,7 @@ test('connection-only CLI bootstrap does not read the model connection catalog',
closed: new Promise<void>(() => {}),
status: async () => ({ state: 'ready' }),
subscribeConfigurationChanges: () => () => {},
subscribeArtifactChanges: () => () => {},
subscribeConnectionCatalogChanges: () => () => {},
subscribeProjectCatalogChanges: () => () => {},
subscribeSessionCatalogChanges: () => () => {},
Expand Down Expand Up @@ -140,6 +142,7 @@ for (const temporary of [true, false]) {
closed: new Promise<void>(() => {}),
status: async () => ({ state: 'ready' }),
subscribeConfigurationChanges: () => () => {},
subscribeArtifactChanges: () => () => {},
subscribeConnectionCatalogChanges: () => () => {},
subscribeProjectCatalogChanges: () => () => {},
subscribeSessionCatalogChanges: () => () => {},
Expand Down Expand Up @@ -210,6 +213,7 @@ test('CLI Runtime Host bootstrap aborts a stalled catalog read and closes its co
closed: new Promise<void>(() => {}),
status: async () => ({ state: 'ready' }),
subscribeConfigurationChanges: () => () => {},
subscribeArtifactChanges: () => () => {},
subscribeConnectionCatalogChanges: () => () => {},
subscribeProjectCatalogChanges: () => () => {},
subscribeSessionCatalogChanges: () => () => {},
Expand Down Expand Up @@ -378,6 +382,7 @@ test('remote CLI profiles pin root identity and resolve credential outside the p
closed: new Promise<void>(() => {}),
status: async () => ({ state: 'ready' }),
subscribeConfigurationChanges: () => () => {},
subscribeArtifactChanges: () => () => {},
subscribeConnectionCatalogChanges: () => () => {},
subscribeProjectCatalogChanges: () => () => {},
subscribeSessionCatalogChanges: () => () => {},
Expand Down Expand Up @@ -485,6 +490,7 @@ test('remote CLI profile state and Client identity use the explicit Client Data
closed: new Promise<void>(() => {}),
status: async () => ({ state: 'ready' }),
subscribeConfigurationChanges: () => () => {},
subscribeArtifactChanges: () => () => {},
subscribeConnectionCatalogChanges: () => () => {},
subscribeProjectCatalogChanges: () => () => {},
subscribeSessionCatalogChanges: () => () => {},
Expand Down Expand Up @@ -564,6 +570,7 @@ test('remote CLI enables SSH prompts only for an explicitly interactive TTY', as
closed: new Promise<void>(() => {}),
status: async () => ({ state: 'ready' }),
subscribeConfigurationChanges: () => () => {},
subscribeArtifactChanges: () => () => {},
subscribeConnectionCatalogChanges: () => () => {},
subscribeProjectCatalogChanges: () => () => {},
subscribeSessionCatalogChanges: () => () => {},
Expand Down Expand Up @@ -736,6 +743,7 @@ test('local CLI delegates a managed cold start once and reconnects without a lau
closed: new Promise<void>(() => {}),
close: async () => {},
subscribeConfigurationChanges: () => () => {},
subscribeArtifactChanges: () => () => {},
subscribeConnectionCatalogChanges: () => () => {},
subscribeProjectCatalogChanges: () => () => {},
subscribeSessionCatalogChanges: () => () => {},
Expand Down Expand Up @@ -843,6 +851,7 @@ for (const action of ['cancel', 'interrupt', 'retry'] as const) {
closed: new Promise<void>(() => {}),
status: async () => ({ state: 'ready' }),
subscribeConfigurationChanges: () => () => {},
subscribeArtifactChanges: () => () => {},
subscribeConnectionCatalogChanges: () => () => {},
subscribeProjectCatalogChanges: () => () => {},
subscribeSessionCatalogChanges: () => () => {},
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/runtime-host-onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ function oauthPhysicalConnection(
return request(operation, input);
},
subscribeConfigurationChanges: () => () => {},
subscribeArtifactChanges: () => () => {},
subscribeConnectionCatalogChanges: () => () => {},
subscribeProjectCatalogChanges: () => () => {},
subscribeSessionCatalogChanges: () => () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,7 @@ function connectionHarness(
return { registrationId: 'registration-a', revision: harness.unregisters };
},
subscribeConfigurationChanges: () => () => undefined,
subscribeArtifactChanges: () => () => undefined,
subscribeConnectionCatalogChanges: () => () => undefined,
subscribeProjectCatalogChanges: () => () => undefined,
subscribeSessionCatalogChanges: () => () => undefined,
Expand Down
Loading