fix(sessions): make the discard a real inverse of the construction

Third review of the reboot-restore branch. The narrow discard the previous
commit introduced avoided everything cleanupSession() did wrongly, and in
dropping so much of it also dropped four things it had to keep.

The worst broke the retry the whole design rests on. setupSessionListeners()
returns early while sessionListenerRefs still holds the session id, and the
discard never cleared that entry. So the advertised flow — a rebuild fails
because the agent binary is missing, the user fixes their PATH and clicks
again — reused the same id, wired no listeners at all, and produced a tab
that never showed output, never updated its status and never persisted. That
is worse than the leak the discard was added to prevent. Three more
registrations leaked with it: a RunSummaryTracker and its interval, an image
watcher on the workspace, and the Ralph fix-plan watcher. The discard now
undoes each registration setupSessionListeners() makes, in its order, and
the per-session custom-model config directory, which holds the endpoint's
API key literally and which nothing else would ever remove.

The image-watcher flag was restored after the code that reads it, so a
session came back reporting the feature as on with nothing watching. It
moves to the before-spawn phase, and that phase now runs before the
listeners rather than after them.

The generation counter that lets a mid-restore dismiss win was global while
clear() is ownership-scoped, so one user's dismiss discarded another user's
unspent entries, permanently, because nothing rebuilds an in-memory plan. It
is now per owner. Bumping only the owners of entries the dismiss removed was
not enough either: take() has already emptied the plan by then, so a dismiss
landing mid-restore saw nothing of that owner's to remove and invalidated
nothing. The owners that matter are those with a restore in flight, filtered
by what the dismissing user may access, and that is what clear() now bumps.
Plan expiry bumps too, so a restore straddling the 24-hour boundary cannot
hand entries back and give an expired plan another full day.

Tests. discardPartiallyBuiltSession had no test at all: the only
implementation any test ran was the mock's one-line stub, which is why every
defect above was invisible. test/discard-partially-built-session.ts drives
the real WebServer, and the retry assertion fails if the listener refs are
left behind — verified by reverting the fix. The dismiss-race test drove the
registry by hand, so deleting the route's generation argument left it green;
it now goes through the route, and two further tests cover the multi-user
cases.

The mock context has now gone stale twice, because route tests pass it as
`ctx as never` and tsconfig.json includes only src, so nothing ever compares
it to the ports. A type-level guard is therefore inert — I wrote one and
confirmed it never fires. test/mocks/mock-route-context-completeness.ts
compares the mock's keys against WebServer.createRouteContext() at runtime
instead, and names what is missing.

Also: the API reference now says workspace-forbidden is judged against the
owner's grant, the banner's module header no longer claims Restore always
dismisses it, and the detail span gets the same min-width: 0 the phone rule
already needed.

Refs #411

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-16 15:34:47 +02:00
co-authored by Claude Opus 5
parent fa52753e8b
commit 71ed7b127c
9 changed files with 322 additions and 56 deletions
+3 -2
View File
@@ -511,8 +511,9 @@ scrollbackRestored: false }`, ownership-scoped in multi-user mode.
- `POST /api/v1/reboot-restore/restore` with `{ sessionIds?: string[] }` (omit - `POST /api/v1/reboot-restore/restore` with `{ sessionIds?: string[] }` (omit
to restore everything the caller can see) → `{ restored: RestorableSession[], to restore everything the caller can see) → `{ restored: RestorableSession[],
skipped: { sessionId, reason }[] }`. `reason` is one of `workspace-missing` skipped: { sessionId, reason }[] }`. `reason` is one of `workspace-missing`
(the directory is gone), `workspace-forbidden` (it is outside the caller's (the directory is gone), `workspace-forbidden` (in multi-user mode it is
workspace in multi-user mode), `already-live` (the conversation is already outside the workspace of the user the session belongs to, re-checked against
that owner's current grant rather than the caller's), `already-live` (the conversation is already
open, typically resumed by hand from the Resume list), `capacity-reached` open, typically resumed by hand from the Resume list), `capacity-reached`
(the global or per-user session cap), or `rebuild-failed` (the agent would not (the global or per-user session cap), or `rebuild-failed` (the agent would not
start, most often a CLI binary missing from the server's PATH). start, most often a CLI binary missing from the server's PATH).
+6 -4
View File
@@ -11,10 +11,12 @@
* Seeded from `GET /api/reboot-restore` on init and again on every SSE reconnect, * Seeded from `GET /api/reboot-restore` on init and again on every SSE reconnect,
* because the tab most likely to want this is one that was open across the reboot * because the tab most likely to want this is one that was open across the reboot
* and reconnects to a server that came back up with an empty board. Restore posts to * and reconnects to a server that came back up with an empty board. Restore posts to
* `POST /api/reboot-restore/restore`, Dismiss posts to * `POST /api/reboot-restore/restore` and Dismiss posts to
* `POST /api/reboot-restore/dismiss`, and either way the banner goes away. The * `POST /api/reboot-restore/dismiss`. Dismiss always clears the banner; Restore
* restored sessions arrive as ordinary `session:created` events, so no extra * re-reads the plan afterwards, because the server puts back anything it could
* rendering is needed here. * not build for a reason that may pass, such as a session limit or an agent that
* would not start. The restored sessions arrive as ordinary `session:created`
* events, so no extra rendering is needed here.
* *
* The banner says that terminal history did not survive, because a restored * The banner says that terminal history did not survive, because a restored
* session is a new pane: the conversation continues and the scrollback does not. * session is a new pane: the conversation continues and the scrollback does not.
+4
View File
@@ -15277,6 +15277,10 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
.reboot-restore-banner-detail { .reboot-restore-banner-detail {
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
font-weight: 500; font-weight: 500;
/* A flex item will not shrink below its content width at the default
`min-width: auto`, so without this the session names push the buttons out of
the line between the phone breakpoint and full width. */
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
+55 -18
View File
@@ -48,12 +48,16 @@ export class RebootRestoreRegistry {
/** When the boot pass built the plan, in ms since the epoch. */ /** When the boot pass built the plan, in ms since the epoch. */
private builtAt = 0; private builtAt = 0;
/** /**
* Bumped by anything that invalidates entries a restore is already holding. * Per owner, bumped by anything that invalidates that owner's entries while a
* A Dismiss arriving mid-restore must win: without this the route's `finally` * restore is already holding them. A Dismiss arriving mid-restore must win:
* would put its unspent entries back and resurrect the offer the user just * without this the route's `finally` would put its unspent entries back and
* cleared, with a fresh 24-hour life. * resurrect the offer the user just cleared, with a fresh 24-hour life.
*
* Keyed by owner rather than global, because `clear()` is ownership-scoped. A
* single counter would let one user's Dismiss discard another user's unspent
* entries, and the plan is in-memory, so those offers would be gone for good.
*/ */
private generation = 0; private generations = new Map<string | undefined, number>();
/** /**
* Owners with a restore in flight, between its take and its last pane. * Owners with a restore in flight, between its take and its last pane.
* Keyed by owner so one user's restore does not turn another user's click into * Keyed by owner so one user's restore does not turn another user's click into
@@ -66,12 +70,17 @@ export class RebootRestoreRegistry {
set(entries: readonly RebootRestoreEntry[]): void { set(entries: readonly RebootRestoreEntry[]): void {
this.entries = new Map(entries.map((entry) => [entry.sessionId, entry])); this.entries = new Map(entries.map((entry) => [entry.sessionId, entry]));
this.builtAt = entries.length > 0 ? Date.now() : 0; this.builtAt = entries.length > 0 ? Date.now() : 0;
this.generation += 1; this.bumpAll();
} }
/** The current generation, for a caller that will later return entries. */ /**
currentGeneration(): number { * The generations of the owners of `entries`, for a caller that will hand some
return this.generation; * of them back later. Pass the result to {@link restore}.
*/
snapshotGenerations(entries: readonly RebootRestoreEntry[]): Map<string | undefined, number> {
const snapshot = new Map<string | undefined, number>();
for (const entry of entries) snapshot.set(entry.owner, this.generations.get(entry.owner) ?? 0);
return snapshot;
} }
/** /**
@@ -117,12 +126,20 @@ export class RebootRestoreRegistry {
* hand is NOT put back, because that one cannot stop being true, and an entry * hand is NOT put back, because that one cannot stop being true, and an entry
* the banner keeps re-offering forever is noise only Dismiss can clear. * the banner keeps re-offering forever is noise only Dismiss can clear.
*/ */
restore(entries: readonly RebootRestoreEntry[], generation?: number): void { restore(entries: readonly RebootRestoreEntry[], generations?: ReadonlyMap<string | undefined, number>): void {
// A dismiss (or a fresh boot plan) since the caller took these entries means let added = 0;
// they are no longer wanted back. for (const entry of entries) {
if (generation !== undefined && generation !== this.generation) return; // A dismiss (or a fresh boot plan) for THIS entry's owner since the caller
for (const entry of entries) this.entries.set(entry.sessionId, entry); // took it means it is no longer wanted back. Another owner's dismiss is
if (entries.length > 0 && this.builtAt === 0) this.builtAt = Date.now(); // none of this entry's business.
if (generations) {
const taken = generations.get(entry.owner);
if (taken !== undefined && taken !== (this.generations.get(entry.owner) ?? 0)) continue;
}
this.entries.set(entry.sessionId, entry);
added += 1;
}
if (added > 0 && this.builtAt === 0) this.builtAt = Date.now();
} }
/** Drop the entries a viewer can see. Returns how many went. */ /** Drop the entries a viewer can see. Returns how many went. */
@@ -130,8 +147,14 @@ export class RebootRestoreRegistry {
const removable = [...this.entries.values()].filter((entry) => canAccess(entry.owner)); const removable = [...this.entries.values()].filter((entry) => canAccess(entry.owner));
for (const entry of removable) this.entries.delete(entry.sessionId); for (const entry of removable) this.entries.delete(entry.sessionId);
if (this.entries.size === 0) this.builtAt = 0; if (this.entries.size === 0) this.builtAt = 0;
// Any restore currently in flight must not put its entries back afterwards. // A restore in flight for these owners must not put their entries back. The
this.generation += 1; // in-flight owners are the ones that matter and the ones the plan can no
// longer name: `take()` has already removed their entries, so a dismiss that
// lands mid-restore sees nothing of theirs to remove. The bump is limited to
// owners this caller could see, so it cannot reach anyone else's restore.
const invalidated = new Set(removable.map((entry) => entry.owner));
for (const owner of this.spending) if (canAccess(owner)) invalidated.add(owner);
for (const owner of invalidated) this.bump(owner);
return removable.length; return removable.length;
} }
@@ -155,11 +178,25 @@ export class RebootRestoreRegistry {
this.entries.clear(); this.entries.clear();
this.builtAt = 0; this.builtAt = 0;
this.spending.clear(); this.spending.clear();
this.generation += 1; this.generations.clear();
}
private bump(owner: string | undefined): void {
this.generations.set(owner, (this.generations.get(owner) ?? 0) + 1);
}
/** Invalidate every owner's in-flight returns, including owners not yet seen. */
private bumpAll(): void {
for (const owner of new Set([...this.entries.values()].map((entry) => entry.owner))) this.bump(owner);
for (const owner of [...this.generations.keys()]) this.bump(owner);
} }
private dropIfExpired(): void { private dropIfExpired(): void {
if (this.builtAt > 0 && Date.now() - this.builtAt > PLAN_TTL_MS) { if (this.builtAt > 0 && Date.now() - this.builtAt > PLAN_TTL_MS) {
// Bump before clearing, while the owners are still known: a restore that
// took entries just before the expiry must not hand them back afterwards
// and give an expired plan another full day of life.
this.bumpAll();
this.entries.clear(); this.entries.clear();
this.builtAt = 0; this.builtAt = 0;
} }
+14 -9
View File
@@ -92,8 +92,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
if (!rebootRestoreRegistry.beginSpending(owner)) { if (!rebootRestoreRegistry.beginSpending(owner)) {
return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, 'A reboot restore is already running')); return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, 'A reboot restore is already running'));
} }
const generation = rebootRestoreRegistry.currentGeneration();
const taken = rebootRestoreRegistry.take(canAccess, body.sessionIds); const taken = rebootRestoreRegistry.take(canAccess, body.sessionIds);
const generations = rebootRestoreRegistry.snapshotGenerations(taken);
// Entries nothing built a pane for, returned to the plan on every exit path // Entries nothing built a pane for, returned to the plan on every exit path
// including a throw. Without this a failure between here and the loop would // including a throw. Without this a failure between here and the loop would
// spend the offer and rebuild nothing, and the plan cannot be rebuilt. // spend the offer and rebuild nothing, and the plan cannot be rebuilt.
@@ -184,16 +184,20 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
}); });
await ctx.addSession(session); await ctx.addSession(session);
await ctx.setupSessionListeners(session); // Before the listeners, because setupSessionListeners() reads the
// Shapes the pane, so it has to land before the CLI process starts. // image-watcher flag this phase restores; before the spawn, because the
// custom-model environment and the nice priority shape the process.
await ctx.reapplyPersistedSessionState(session, saved, 'before-spawn'); await ctx.reapplyPersistedSessionState(session, saved, 'before-spawn');
await ctx.setupSessionListeners(session);
await session.startInteractive(); await session.startInteractive();
// The session's own history, applied only once the pane exists: on a // The session's own history, applied only once the pane exists: on a
// failed start these totals would belong to a session that never ran. // failed start these totals would belong to a session that never ran.
// Both halves precede the first persist, because a constructed session // Both halves precede the route's OWN persist, which matters because a
// carries none of this and `toState()` is written wholesale, so // constructed session carries none of this and `toState()` is written
// persisting first would replace the fuller record with the reduced one // wholesale, so persisting first would replace the fuller record with
// and drop the pin that keeps it from being pruned. // the reduced one and drop the pin that keeps it from being pruned. A
// listener-driven persist can still land inside the debounce window
// while the pane starts; the write below repairs the record.
await ctx.reapplyPersistedSessionState(session, saved, 'after-spawn'); await ctx.reapplyPersistedSessionState(session, saved, 'after-spawn');
ctx.persistSessionState(session); ctx.persistSessionState(session);
@@ -248,8 +252,9 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
} finally { } finally {
// Anything that never became a pane goes back on offer, including after a // Anything that never became a pane goes back on offer, including after a
// throw, so a transient failure costs a retry rather than the whole plan. // throw, so a transient failure costs a retry rather than the whole plan.
// Passing the generation makes a Dismiss that landed mid-restore win. // Passing the generations makes a Dismiss that landed mid-restore win, for
rebootRestoreRegistry.restore([...unspent], generation); // the owners it actually covered.
rebootRestoreRegistry.restore([...unspent], generations);
rebootRestoreRegistry.endSpending(owner); rebootRestoreRegistry.endSpending(owner);
} }
}); });
+45 -13
View File
@@ -2923,8 +2923,9 @@ export class WebServer extends EventEmitter {
* Split in two phases because the two halves have opposite timing needs: * Split in two phases because the two halves have opposite timing needs:
* *
* - `before-spawn` shapes the pane itself, so it has to land before the CLI * - `before-spawn` shapes the pane itself, so it has to land before the CLI
* process starts. The custom-model selection is an environment injection and * process starts, and before `setupSessionListeners()`, which reads the
* the nice priority is applied to the spawn. * image-watcher flag. The custom-model selection is an environment injection
* and the nice priority is applied to the spawn.
* - `after-spawn` is the session's own accumulated history. It must NOT land * - `after-spawn` is the session's own accumulated history. It must NOT land
* on a session whose pane failed to start: the totals would then belong to a * on a session whose pane failed to start: the totals would then belong to a
* session that never ran, and any later cleanup would add them to the * session that never ran, and any later cleanup would add them to the
@@ -2952,6 +2953,10 @@ export class WebServer extends EventEmitter {
if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) { if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) {
session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue }); session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue });
} }
// `setupSessionListeners()` READS this flag to decide whether to start the
// watcher, so setting it later would leave the session reporting the feature
// as on with nothing watching.
if (saved.imageWatcherEnabled !== undefined) session.imageWatcherEnabled = saved.imageWatcherEnabled;
return; return;
} }
@@ -2974,7 +2979,6 @@ export class WebServer extends EventEmitter {
}); });
} }
if (saved.color) session.setColor(saved.color); if (saved.color) session.setColor(saved.color);
if (saved.imageWatcherEnabled !== undefined) session.imageWatcherEnabled = saved.imageWatcherEnabled;
if (saved.flickerFilterEnabled !== undefined) session.flickerFilterEnabled = saved.flickerFilterEnabled; if (saved.flickerFilterEnabled !== undefined) session.flickerFilterEnabled = saved.flickerFilterEnabled;
} }
@@ -2989,33 +2993,61 @@ export class WebServer extends EventEmitter {
* WORKING DIRECTORY, which belongs to the workspace rather than to this session * WORKING DIRECTORY, which belongs to the workspace rather than to this session
* and may hold another live session's pasted images. * and may hold another live session's pasted images.
* *
* This undoes only what the failed construction did: the map entry, the tab * Everything else `_doCleanupSession()` does, this has to do as well. It is the
* layout slot `registerSessionWithLayout()` took, and any pane the CLI launch * inverse of `registerSessionWithLayout()` plus `setupSessionListeners()`, and
* managed to create before it threw. The persisted record is left exactly as it * every registration those two make has to come back out — above all
* was, so the session stays restorable on the next attempt. * `sessionListenerRefs`, whose presence makes `setupSessionListeners()` return
* early. Leaving that entry behind is worse than the leak this function exists
* to prevent: the retry reuses the same session id, wires no listeners at all,
* and the user gets a tab that never shows output.
*
* The persisted record, the lifetime totals, the stored Ralph state and the
* workspace's own files are left exactly as they were, so the session stays
* restorable on the next attempt.
*/ */
async discardPartiallyBuiltSession(sessionId: string): Promise<void> { async discardPartiallyBuiltSession(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return; if (!session) return;
this.sessions.delete(sessionId); this.sessions.delete(sessionId);
// --- the inverse of setupSessionListeners(), in its order ---
const summaryTracker = this.runSummaryTrackers.get(sessionId);
if (summaryTracker) {
summaryTracker.stop();
this.runSummaryTrackers.delete(sessionId);
}
// An fs.watch on the workspace (or on @fix_plan.md) that nothing else closes.
session.ralphTracker.stopWatchingFixPlan();
// An FSWatcher on the workspace, likewise.
imageWatcher.unwatchSession(sessionId);
const listeners = this.sessionListenerRefs.get(sessionId);
if (listeners) {
detachSessionListeners(session, listeners);
this.sessionListenerRefs.delete(sessionId);
}
// --- the inverse of the construction itself ---
this.sse.cleanupSessionBatches(sessionId); this.sse.cleanupSessionBatches(sessionId);
this.persistDeb.cancelKey(sessionId); this.persistDeb.cancelKey(sessionId);
fileStreamManager.closeSessionStreams(sessionId);
// The per-session custom-model config dir carries the endpoint's API key, and
// `before-spawn` may already have written it. Nothing else would ever remove
// it: the stale sweep only touches state.json. A retry rewrites it.
removeConfigDir(customModelConfigDir(sessionId));
try { try {
session.removeAllListeners(); session.removeAllListeners();
await session.stop?.(); await session.stop(true);
} catch (err) { } catch (err) {
console.warn(`[Server] stopping a partially built session failed: ${getErrorMessage(err)}`); console.warn(`[Server] stopping a partially built session failed: ${getErrorMessage(err)}`);
} }
try {
await this.mux.killSession(sessionId);
} catch {
// The pane may never have been created; nothing to kill is the normal case.
}
try { try {
await this.tabLayouts.sessionsRemoved([{ id: sessionId, owner: session.owner }]); await this.tabLayouts.sessionsRemoved([{ id: sessionId, owner: session.owner }]);
} catch (err) { } catch (err) {
console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`); console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`);
} }
// Any `session:updated` the half-built session emitted before it failed left a
// tab on every other open board, and the client's handler is an upsert.
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
} }
private async restoreMuxSessions(): Promise<boolean> { private async restoreMuxSessions(): Promise<boolean> {
@@ -0,0 +1,113 @@
/**
* `WebServer.discardPartiallyBuiltSession()` against the real server object.
*
* The reboot-restore route calls this when a rebuild registers a session and
* then fails to start its pane. It has to be the exact inverse of
* `registerSessionWithLayout()` plus `setupSessionListeners()`, and it must NOT
* be the user-initiated delete: banking the session's token totals, demoting a
* pinned record or deleting the workspace's files would all be wrong for a
* session that never ran.
*
* These tests drive the real method rather than the route, because the route
* tests run against a mock context whose `discardPartiallyBuiltSession` is a
* one-line stub — an earlier version of this function left four registrations
* behind and every route test still passed.
*
* The retry assertion is the important one. `setupSessionListeners()` returns
* early when `sessionListenerRefs` still holds the session id, so a discard that
* leaves that entry makes the next attempt wire nothing at all, and the user
* gets a tab that never shows output.
*/
import { mkdirSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
/** Reach the private collections the discard is responsible for emptying. */
interface ServerInternals {
sessions: Map<string, Session>;
sessionListenerRefs: Map<string, unknown>;
runSummaryTrackers: Map<string, unknown>;
registerSessionWithLayout(session: Session): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
}
const WORKSPACE = join(homedir(), '.codeman-test-discard');
const SESSION_ID = 'a1b2c3d4e5f60718';
let server: WebServer;
let internals: ServerInternals;
let mux: TmuxManager;
function buildSession(): Session {
return new Session({
id: SESSION_ID,
workingDir: WORKSPACE,
mode: 'claude',
name: 'rebuilt session',
mux,
useMux: true,
});
}
beforeEach(() => {
mkdirSync(WORKSPACE, { recursive: true });
// Test mode: no port is opened and no CLI is launched.
server = new WebServer(0, false, true);
internals = server as unknown as ServerInternals;
mux = new TmuxManager();
});
afterEach(async () => {
await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {});
rmSync(WORKSPACE, { recursive: true, force: true });
});
describe('discarding a session whose pane never started', () => {
it('takes the session back out of the server', async () => {
const session = buildSession();
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
expect(internals.sessions.has(SESSION_ID)).toBe(true);
await internals.discardPartiallyBuiltSession(SESSION_ID);
expect(internals.sessions.has(SESSION_ID)).toBe(false);
});
it('releases the listener registration, so a retry can wire itself again', async () => {
const first = buildSession();
await internals.registerSessionWithLayout(first);
await internals.setupSessionListeners(first);
expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(true);
await internals.discardPartiallyBuiltSession(SESSION_ID);
expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(false);
// The retry reuses the id by design. `setupSessionListeners()` returns early
// while the refs are still there, so a session built now would run blind:
// no terminal output, no status updates, no exit broadcast.
const retry = buildSession();
await internals.registerSessionWithLayout(retry);
await internals.setupSessionListeners(retry);
expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(true);
});
it('stops the run-summary tracker, whose interval would otherwise keep firing', async () => {
const session = buildSession();
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
expect(internals.runSummaryTrackers.has(SESSION_ID)).toBe(true);
await internals.discardPartiallyBuiltSession(SESSION_ID);
expect(internals.runSummaryTrackers.has(SESSION_ID)).toBe(false);
});
it('does nothing at all for a session it never registered', async () => {
await expect(internals.discardPartiallyBuiltSession('never-existed')).resolves.toBeUndefined();
});
});
@@ -0,0 +1,28 @@
/**
* The mock route context must offer everything the real one does.
*
* Route tests pass their context as `ctx as never`, and `tsconfig.json` includes
* only `src/**`, so no type check ever compares the mock against the ports. A
* port that gained a method left this mock missing it twice; both times the
* route under test threw a TypeError inside its own catch, and the suite
* reported a plausible-looking failure for an unrelated reason.
*
* So the comparison is made at runtime, against `WebServer.createRouteContext()`
* rather than against the port types, which is what keeps it from drifting: the
* server's own context object is the thing route modules are really given.
*/
import { describe, expect, it } from 'vitest';
import { WebServer } from '../../src/web/server.js';
import { createMockRouteContext } from './mock-route-context.js';
describe('the mock route context', () => {
it('offers every member the real route context does', () => {
const server = new WebServer(0, false, true);
const real = (server as unknown as { createRouteContext(): Record<string, unknown> }).createRouteContext();
const mock = createMockRouteContext() as unknown as Record<string, unknown>;
const missing = Object.keys(real).filter((key) => !(key in mock));
expect(missing, `mock-route-context.ts is missing: ${missing.join(', ')}`).toEqual([]);
});
});
@@ -282,18 +282,62 @@ describe('a failure before any entry is considered', () => {
}); });
describe('a dismiss that lands while a restore is running', () => { describe('a dismiss that lands while a restore is running', () => {
it('wins, rather than being undone when the restore hands its entries back', async () => { it('wins, rather than being undone when the route hands its entries back', async () => {
const entries = [offerEntry('a')]; rebootRestoreRegistry.set([offerEntry('a')]);
rebootRestoreRegistry.set(entries); const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
const generation = rebootRestoreRegistry.currentGeneration(); // The user clicks Dismiss while the restore is between its take and its
const taken = rebootRestoreRegistry.take(() => true); // return. Driven through the ROUTE, so removing the generation argument from
expect(taken).toHaveLength(1); // the route would make this fail.
(ctx.getWorkspaceHooksEnabled as ReturnType<typeof vi.fn>).mockImplementation(async () => {
rebootRestoreRegistry.clear(() => true);
throw new Error('settings unreadable');
});
const app = await createHarness(ctx);
// The user clears the banner while the restore is still working. await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
rebootRestoreRegistry.clear(() => true);
// The restore finishes and tries to put its unspent entry back. const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
rebootRestoreRegistry.restore(taken, generation); expect(left.sessions).toEqual([]);
await app.close();
});
it('reaches an in-flight restore the dismisser can see, even once its entries are taken', async () => {
const mine = offerEntry('mine', 'alice');
rebootRestoreRegistry.set([mine]);
expect(rebootRestoreRegistry.beginSpending('alice')).toBe(true);
const generations = rebootRestoreRegistry.snapshotGenerations([mine]);
const taken = rebootRestoreRegistry.take((owner) => owner === 'alice');
// The plan is empty now, so a dismiss has nothing of Alice's to remove; the
// invalidation has to come from her claimed flight.
rebootRestoreRegistry.clear((owner) => owner === 'alice');
rebootRestoreRegistry.restore(taken, generations);
rebootRestoreRegistry.endSpending('alice');
expect(rebootRestoreRegistry.list(() => true)).toEqual([]); expect(rebootRestoreRegistry.list(() => true)).toEqual([]);
}); });
it('does not reach another owner, whose unspent entries still come back', async () => {
const mine = offerEntry('mine', 'alice');
const theirs = offerEntry('theirs', 'bob');
rebootRestoreRegistry.set([mine, theirs]);
// Bob is mid-restore, holding his own entry. The claimed flight is what makes
// this the interesting case: a dismiss can no longer see Bob's entries in the
// plan, so the invalidation has to come from the in-flight set, filtered by
// what the dismissing user may access.
expect(rebootRestoreRegistry.beginSpending('bob')).toBe(true);
const bobsGenerations = rebootRestoreRegistry.snapshotGenerations([theirs]);
const bobsTaken = rebootRestoreRegistry.take((owner) => owner === 'bob');
expect(bobsTaken.map((e) => e.sessionId)).toEqual(['theirs']);
// Alice dismisses her own banner meanwhile.
rebootRestoreRegistry.clear((owner) => owner === 'alice');
// Bob's restore finishes and hands his entry back. Alice's dismiss covered
// her entries, not his, so his offer survives.
rebootRestoreRegistry.restore(bobsTaken, bobsGenerations);
rebootRestoreRegistry.endSpending('bob');
expect(rebootRestoreRegistry.list(() => true).map((e) => e.sessionId)).toEqual(['theirs']);
});
}); });