Skip to content

Commit 4438f30

Browse files
committed
Extract three module globals into RuntimeSingletons container for atomic test swap
1 parent 31f0b94 commit 4438f30

3 files changed

Lines changed: 121 additions & 24 deletions

File tree

notebook/store.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,39 +10,37 @@ import {
1010
DEFAULT_MAX_LINES,
1111
truncateHead,
1212
} from "@earendil-works/pi-coding-agent";
13-
import { AsyncLocalStorage } from "node:async_hooks";
1413
import type { AgenticodingState } from "../state.js";
15-
16-
/**
17-
* Module-level write lock state.
18-
*
19-
* Concurrent callers serialize by chaining on the prior promise. Reentrancy is
20-
* tracked per async call chain so a nested saveNotebookPage fails explicitly
21-
* without rejecting unrelated concurrent writers that happen to overlap.
22-
*/
23-
let writeLock: Promise<void> = Promise.resolve();
24-
const writeContext = new AsyncLocalStorage<true>();
14+
import { createWriteLock, __setSingletons, getSingletons } from "../runtime-singletons.js";
2515

2616
/** Reset write lock state. Only for test cleanup after concurrent runs. */
2717
export function resetNotebookWriteLock(): void {
28-
writeLock = Promise.resolve();
18+
__setSingletons(
19+
{ ...getSingletons(), writeLock: createWriteLock() },
20+
{ forceWriteLock: true },
21+
);
2922
}
3023

3124
async function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
32-
if (writeContext.getStore()) {
25+
const s = getSingletons();
26+
const lock = s.writeLock;
27+
if (s.writeContext.getStore()) {
3328
throw new Error(
3429
"Notebook write lock is not reentrant — saveNotebookPage called from within its own critical section.",
3530
);
3631
}
3732
let release: () => void;
38-
const prev = writeLock;
39-
writeLock = new Promise<void>((resolve) => {
33+
const prev = lock.tail;
34+
const next = new Promise<void>((resolve) => {
4035
release = resolve;
4136
});
37+
lock.pending += 1;
38+
lock.tail = next;
4239
await prev;
4340
try {
44-
return await writeContext.run(true, fn);
41+
return await s.writeContext.run(true, fn);
4542
} finally {
43+
lock.pending -= 1;
4644
release!();
4745
}
4846
}

runtime-singletons.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Shared singleton container for the agenticoding extension.
3+
*
4+
* Allows tests to replace all module-level singletons (write lock, frame
5+
* scheduler, etc.) with one atomic swap via __setSingletons(), instead of
6+
* patching each singleton individually per test.
7+
*
8+
* In production the frame scheduler is registered by spawn/renderer.ts at
9+
* module import time. In tests, createTestHarness() provides a fresh
10+
* container that tests own and dispose.
11+
*/
12+
13+
import { AsyncLocalStorage } from "node:async_hooks";
14+
15+
// ── Types ─────────────────────────────────────────────────────────────
16+
17+
/** Minimal frame scheduler interface that the container understands. */
18+
export interface RuntimeFrameScheduler {
19+
markDirty(component: unknown): void;
20+
cancelDirty(component: unknown): void;
21+
flushNow(): void;
22+
clear(): void;
23+
}
24+
25+
export interface RuntimeWriteLock {
26+
pending: number;
27+
tail: Promise<void>;
28+
}
29+
30+
export interface RuntimeSingletons {
31+
writeLock: RuntimeWriteLock;
32+
writeContext: AsyncLocalStorage<true>;
33+
frameScheduler: RuntimeFrameScheduler;
34+
}
35+
36+
export function createWriteLock(): RuntimeWriteLock {
37+
return {
38+
pending: 0,
39+
tail: Promise.resolve(),
40+
};
41+
}
42+
43+
// ── Pre‑init defaults (overwritten by spawn/renderer.ts at import time) ──
44+
45+
let current: RuntimeSingletons = {
46+
writeLock: createWriteLock(),
47+
writeContext: new AsyncLocalStorage<true>(),
48+
frameScheduler: {
49+
markDirty: () => {},
50+
cancelDirty: () => {},
51+
flushNow: () => {},
52+
clear: () => {},
53+
},
54+
};
55+
56+
// ── Public API ────────────────────────────────────────────────────────
57+
58+
/** Atomically replace all singletons. Test‑only — use __ naming convention. */
59+
export function __setSingletons(
60+
s: RuntimeSingletons,
61+
options?: { forceWriteLock?: boolean },
62+
): void {
63+
if (!options?.forceWriteLock && current.writeLock.pending > 0) {
64+
console.warn(
65+
"[runtime-singletons] writeLock has %d pending operation(s) — " +
66+
"preserving existing lock chain to avoid breaking in-flight writes. " +
67+
"Use { forceWriteLock: true } to override.",
68+
current.writeLock.pending,
69+
);
70+
current = { ...s, writeLock: current.writeLock };
71+
return;
72+
}
73+
current = s;
74+
}
75+
76+
/** Read the current singleton container. */
77+
export function getSingletons(): RuntimeSingletons {
78+
return current;
79+
}

spawn/renderer.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
3232
import { Container, Spacer, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3333
import type { TUI } from "@earendil-works/pi-tui";
3434
import type { AgenticodingState } from "../state.js";
35+
import {
36+
__setSingletons,
37+
getSingletons,
38+
} from "../runtime-singletons.js";
3539
import {
3640
getLastAssistantText,
3741
type SpawnOutcome,
@@ -249,7 +253,7 @@ interface SpawnFrameTarget {
249253
* streaming events (50-100+/sec) do not trigger an equal number of heavy
250254
* component mutations.
251255
*/
252-
class SpawnFrameScheduler {
256+
export class SpawnFrameScheduler {
253257
private readonly frameMs: number;
254258
private dirtyComponents = new Set<SpawnFrameTarget>();
255259
private frameTimer: ReturnType<typeof setTimeout> | null = null;
@@ -316,8 +320,20 @@ class SpawnFrameScheduler {
316320
}
317321
}
318322

319-
/** Module-level singleton shared by all NestedAgentSessionComponent instances. */
323+
/**
324+
* Module-level singleton shared by all NestedAgentSessionComponent instances.
325+
*
326+
* Registered into the RuntimeSingletons container at module evaluation time.
327+
* Test harnesses overwrite this with a fresh SpawnFrameScheduler via
328+
* createTestHarness(). ESM guarantees all static imports resolve before any
329+
* module body runs, so the harness always wins.
330+
*
331+
* IMPORTANT: never use dynamic import() to load this module *after* a
332+
* createTestHarness() call, or the production scheduler will overwrite the
333+
* test one.
334+
*/
320335
const spawnFrameScheduler = new SpawnFrameScheduler();
336+
__setSingletons({ ...getSingletons(), frameScheduler: spawnFrameScheduler });
321337

322338
// ── NestedAgentSessionComponent ───────────────────────────────────────
323339

@@ -396,7 +412,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget
396412
this.renderQueued = false;
397413
this.queuedRenderToken = undefined;
398414
this.renderScheduleToken++;
399-
spawnFrameScheduler.cancelDirty(this);
415+
getSingletons().frameScheduler.cancelDirty(this);
400416
}
401417

402418
/**
@@ -409,7 +425,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget
409425
if (this.renderQueued) return;
410426
this.renderQueued = true;
411427
this.queuedRenderToken = ++this.renderScheduleToken;
412-
spawnFrameScheduler.markDirty(this);
428+
getSingletons().frameScheduler.markDirty(this);
413429
}
414430

415431
/**
@@ -555,7 +571,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget
555571
dispose(): void {
556572
this.unsubscribe?.();
557573
this.unsubscribe = undefined;
558-
spawnFrameScheduler.cancelDirty(this);
574+
getSingletons().frameScheduler.cancelDirty(this);
559575
this.clearPendingState();
560576
// Snapshot fields before clearing: if session.abort() triggers re-entrant
561577
// dispose, the nulled-out fields prevent double-abort.
@@ -731,7 +747,7 @@ class NestedAgentSessionComponent extends Container implements SpawnFrameTarget
731747
if (!this.session) return;
732748

733749
// Flush any pending state first so accumulated updates don't double-apply
734-
spawnFrameScheduler.cancelDirty(this);
750+
getSingletons().frameScheduler.cancelDirty(this);
735751
this.clearPendingState();
736752

737753
this.clear();
@@ -1209,16 +1225,20 @@ export { NestedAgentSessionComponent, renderSpawnCall, renderSpawnResult };
12091225
* Synchronously flush all pending spawn frame work.
12101226
* Exported for tests. Not needed in production — the frame timer handles
12111227
* everything automatically.
1228+
*
1229+
* Delegate through getSingletons() so that test harness swaps are respected.
12121230
*/
12131231
export function flushSpawnFrameScheduler(): void {
1214-
spawnFrameScheduler.flushNow();
1232+
getSingletons().frameScheduler.flushNow();
12151233
}
12161234

12171235
/**
12181236
* Reset the frame scheduler, discarding any pending dirty markers.
12191237
* Exported for tests. In production the scheduler lifecycle is tied to
12201238
* component dispose(), so this is never needed.
1239+
*
1240+
* Delegate through getSingletons() so that test harness swaps are respected.
12211241
*/
12221242
export function resetSpawnFrameScheduler(): void {
1223-
spawnFrameScheduler.clear();
1243+
getSingletons().frameScheduler.clear();
12241244
}

0 commit comments

Comments
 (0)