mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
co-authored by
Claude Opus 5
parent
fa52753e8b
commit
71ed7b127c
@@ -511,8 +511,9 @@ scrollbackRestored: false }`, ownership-scoped in multi-user mode.
|
||||
- `POST /api/v1/reboot-restore/restore` with `{ sessionIds?: string[] }` (omit
|
||||
to restore everything the caller can see) → `{ restored: RestorableSession[],
|
||||
skipped: { sessionId, reason }[] }`. `reason` is one of `workspace-missing`
|
||||
(the directory is gone), `workspace-forbidden` (it is outside the caller's
|
||||
workspace in multi-user mode), `already-live` (the conversation is already
|
||||
(the directory is gone), `workspace-forbidden` (in multi-user mode it is
|
||||
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`
|
||||
(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).
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
* 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
|
||||
* 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/dismiss`, and either way the banner goes away. The
|
||||
* restored sessions arrive as ordinary `session:created` events, so no extra
|
||||
* rendering is needed here.
|
||||
* `POST /api/reboot-restore/restore` and Dismiss posts to
|
||||
* `POST /api/reboot-restore/dismiss`. Dismiss always clears the banner; Restore
|
||||
* re-reads the plan afterwards, because the server puts back anything it could
|
||||
* 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
|
||||
* session is a new pane: the conversation continues and the scrollback does not.
|
||||
|
||||
@@ -15277,6 +15277,10 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
.reboot-restore-banner-detail {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
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;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -48,12 +48,16 @@ export class RebootRestoreRegistry {
|
||||
/** When the boot pass built the plan, in ms since the epoch. */
|
||||
private builtAt = 0;
|
||||
/**
|
||||
* Bumped by anything that invalidates entries a restore is already holding.
|
||||
* A Dismiss arriving mid-restore must win: without this the route's `finally`
|
||||
* would put its unspent entries back and resurrect the offer the user just
|
||||
* cleared, with a fresh 24-hour life.
|
||||
* Per owner, bumped by anything that invalidates that owner's entries while a
|
||||
* restore is already holding them. A Dismiss arriving mid-restore must win:
|
||||
* without this the route's `finally` would put its unspent entries back and
|
||||
* 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.
|
||||
* 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 {
|
||||
this.entries = new Map(entries.map((entry) => [entry.sessionId, entry]));
|
||||
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 {
|
||||
return this.generation;
|
||||
/**
|
||||
* The generations of the owners of `entries`, for a caller that will hand some
|
||||
* 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
|
||||
* the banner keeps re-offering forever is noise only Dismiss can clear.
|
||||
*/
|
||||
restore(entries: readonly RebootRestoreEntry[], generation?: number): void {
|
||||
// A dismiss (or a fresh boot plan) since the caller took these entries means
|
||||
// they are no longer wanted back.
|
||||
if (generation !== undefined && generation !== this.generation) return;
|
||||
for (const entry of entries) this.entries.set(entry.sessionId, entry);
|
||||
if (entries.length > 0 && this.builtAt === 0) this.builtAt = Date.now();
|
||||
restore(entries: readonly RebootRestoreEntry[], generations?: ReadonlyMap<string | undefined, number>): void {
|
||||
let added = 0;
|
||||
for (const entry of entries) {
|
||||
// A dismiss (or a fresh boot plan) for THIS entry's owner since the caller
|
||||
// took it means it is no longer wanted back. Another owner's dismiss is
|
||||
// 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. */
|
||||
@@ -130,8 +147,14 @@ export class RebootRestoreRegistry {
|
||||
const removable = [...this.entries.values()].filter((entry) => canAccess(entry.owner));
|
||||
for (const entry of removable) this.entries.delete(entry.sessionId);
|
||||
if (this.entries.size === 0) this.builtAt = 0;
|
||||
// Any restore currently in flight must not put its entries back afterwards.
|
||||
this.generation += 1;
|
||||
// A restore in flight for these owners must not put their entries back. The
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -155,11 +178,25 @@ export class RebootRestoreRegistry {
|
||||
this.entries.clear();
|
||||
this.builtAt = 0;
|
||||
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 {
|
||||
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.builtAt = 0;
|
||||
}
|
||||
|
||||
@@ -92,8 +92,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
|
||||
if (!rebootRestoreRegistry.beginSpending(owner)) {
|
||||
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 generations = rebootRestoreRegistry.snapshotGenerations(taken);
|
||||
// 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
|
||||
// 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.setupSessionListeners(session);
|
||||
// Shapes the pane, so it has to land before the CLI process starts.
|
||||
// Before the listeners, because setupSessionListeners() reads the
|
||||
// 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.setupSessionListeners(session);
|
||||
await session.startInteractive();
|
||||
// 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.
|
||||
// Both halves precede the first persist, because a constructed session
|
||||
// carries none of this and `toState()` is written wholesale, so
|
||||
// persisting first would replace the fuller record with the reduced one
|
||||
// and drop the pin that keeps it from being pruned.
|
||||
// Both halves precede the route's OWN persist, which matters because a
|
||||
// constructed session carries none of this and `toState()` is written
|
||||
// wholesale, so persisting first would replace the fuller record with
|
||||
// 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');
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
@@ -248,8 +252,9 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
|
||||
} finally {
|
||||
// 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.
|
||||
// Passing the generation makes a Dismiss that landed mid-restore win.
|
||||
rebootRestoreRegistry.restore([...unspent], generation);
|
||||
// Passing the generations makes a Dismiss that landed mid-restore win, for
|
||||
// the owners it actually covered.
|
||||
rebootRestoreRegistry.restore([...unspent], generations);
|
||||
rebootRestoreRegistry.endSpending(owner);
|
||||
}
|
||||
});
|
||||
|
||||
+45
-13
@@ -2923,8 +2923,9 @@ export class WebServer extends EventEmitter {
|
||||
* 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
|
||||
* process starts. The custom-model selection is an environment injection and
|
||||
* the nice priority is applied to the spawn.
|
||||
* process starts, and before `setupSessionListeners()`, which reads the
|
||||
* 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
|
||||
* 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
|
||||
@@ -2952,6 +2953,10 @@ export class WebServer extends EventEmitter {
|
||||
if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2974,7 +2979,6 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
}
|
||||
if (saved.color) session.setColor(saved.color);
|
||||
if (saved.imageWatcherEnabled !== undefined) session.imageWatcherEnabled = saved.imageWatcherEnabled;
|
||||
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
|
||||
* and may hold another live session's pasted images.
|
||||
*
|
||||
* This undoes only what the failed construction did: the map entry, the tab
|
||||
* layout slot `registerSessionWithLayout()` took, and any pane the CLI launch
|
||||
* managed to create before it threw. The persisted record is left exactly as it
|
||||
* was, so the session stays restorable on the next attempt.
|
||||
* Everything else `_doCleanupSession()` does, this has to do as well. It is the
|
||||
* inverse of `registerSessionWithLayout()` plus `setupSessionListeners()`, and
|
||||
* every registration those two make has to come back out — above all
|
||||
* `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> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
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.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 {
|
||||
session.removeAllListeners();
|
||||
await session.stop?.();
|
||||
await session.stop(true);
|
||||
} catch (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 {
|
||||
await this.tabLayouts.sessionsRemoved([{ id: sessionId, owner: session.owner }]);
|
||||
} catch (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> {
|
||||
|
||||
@@ -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', () => {
|
||||
it('wins, rather than being undone when the restore hands its entries back', async () => {
|
||||
const entries = [offerEntry('a')];
|
||||
rebootRestoreRegistry.set(entries);
|
||||
const generation = rebootRestoreRegistry.currentGeneration();
|
||||
const taken = rebootRestoreRegistry.take(() => true);
|
||||
expect(taken).toHaveLength(1);
|
||||
it('wins, rather than being undone when the route hands its entries back', async () => {
|
||||
rebootRestoreRegistry.set([offerEntry('a')]);
|
||||
const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
|
||||
// The user clicks Dismiss while the restore is between its take and its
|
||||
// return. Driven through the ROUTE, so removing the generation argument from
|
||||
// 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.
|
||||
rebootRestoreRegistry.clear(() => true);
|
||||
// The restore finishes and tries to put its unspent entry back.
|
||||
rebootRestoreRegistry.restore(taken, generation);
|
||||
await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
|
||||
|
||||
const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
|
||||
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([]);
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user