From 063f5a01f6cd477be853e570ddd604ff052d41dc Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Wed, 21 Oct 2026 02:14:23 +0200 Subject: [PATCH 1/4] CONTRACT: fix(ui-server): cancel queued module metadata --- .changeset/quiet-metadata-admission.md | 5 + api-report/ui-server.txt | 4 +- docs/decisions/claude-code-runtime.md | 4 +- docs/decisions/governed-writable-workers.md | 2 +- docs/decisions/http-api-boundary.md | 13 +- docs/decisions/runtime-requirements.md | 4 +- docs/decisions/session-principals.md | 2 +- docs/hosting/README.md | 2 +- docs/integration-contract/package-api.md | 14 ++ docs/plans/storage-lifecycle.md | 2 +- packages/ui-server/src/app.ts | 8 +- packages/ui-server/src/brain/client.ts | 23 ++- packages/ui-server/src/brain/host-boundary.ts | 26 ++- packages/ui-server/src/routes/health.ts | 4 +- packages/ui-server/src/routes/modules.ts | 4 +- .../tests/app-metadata-cancellation.test.ts | 193 ++++++++++++++++++ 16 files changed, 275 insertions(+), 35 deletions(-) create mode 100644 .changeset/quiet-metadata-admission.md create mode 100644 packages/ui-server/tests/app-metadata-cancellation.test.ts diff --git a/.changeset/quiet-metadata-admission.md b/.changeset/quiet-metadata-admission.md new file mode 100644 index 000000000..6411470a3 --- /dev/null +++ b/.changeset/quiet-metadata-admission.md @@ -0,0 +1,5 @@ +--- +"@schlessera/brain-ui-server": minor +--- + +Cancel queued module metadata when its HTTP caller disconnects or the app closes, while draining admitted workers and retaining database ownership until handlers settle. Add an optional admission-cancellation signal to `BrainClient.modules`. diff --git a/api-report/ui-server.txt b/api-report/ui-server.txt index 6ff784b14..35b453c71 100644 --- a/api-report/ui-server.txt +++ b/api-report/ui-server.txt @@ -252,7 +252,9 @@ signatures (every public export and the types it is made of) export interface BrainClient { hygiene?(args: readonly string[]): Promise; cliCommand(): string[]; - modules?(): Promise<{ + modules?(opts?: { + signal?: AbortSignal; + }): Promise<{ enabled: ConfiguredModule[]; available: unknown[]; }>; diff --git a/docs/decisions/claude-code-runtime.md b/docs/decisions/claude-code-runtime.md index 6d6358183..ab6da4228 100644 --- a/docs/decisions/claude-code-runtime.md +++ b/docs/decisions/claude-code-runtime.md @@ -63,7 +63,7 @@ next to it. - **Updated.** Nothing in the tree installs, updates, pins or reads the version of this binary. The only version probe in the server is for the `brain` CLI (`Probe the selected content CLI`, - `packages/ui-server/src/brain/client.ts:121-187`, called at + `packages/ui-server/src/brain/client.ts:122-190`, called at `probeBrainCliVersion(config.brainPath`, `packages/ui-server/src/app.ts:402`). `brain doctor` runs `claude mcp list` from `PATH` (`which("claude")`, `packages/core/src/cli/commands/doctor.ts:495-499`) — the user's own Claude @@ -243,7 +243,7 @@ than refuses on a mismatch.** pair is one nobody measured. - **At boot, from the binary a turn would spawn.** The same shape as the `brain` CLI probe (`Probe the selected content CLI`, - `packages/ui-server/src/brain/client.ts:121`). The SDK's + `packages/ui-server/src/brain/client.ts:122`). The SDK's resolver is not exported, so the probe must not re-implement it. The SDK resolves the binary when a query is built, and fails there if none is found (`node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs:228`); it then hands diff --git a/docs/decisions/governed-writable-workers.md b/docs/decisions/governed-writable-workers.md index 3af8f1786..423e32fb3 100644 --- a/docs/decisions/governed-writable-workers.md +++ b/docs/decisions/governed-writable-workers.md @@ -2007,7 +2007,7 @@ files, `.claude/`, `.agents/`, `.pi/`, `.mcp.json`, and - The core CLI loads `brain.config.ts` or `brain.config.json` at the canonical brain cwd (`loadUserConfig`, `packages/core/src/lib/config.ts:590-624`). Host commands bind that cwd to the selected brain (`function hostSpawn`, - `packages/ui-server/src/brain/host-boundary.ts:429-440`). + `packages/ui-server/src/brain/host-boundary.ts:432-443`). - Bun startup uses root `bunfig.toml` and `package.json`; retained literal preload/module inputs can identify a nested module's nearest package scope. The inventory walks parent directories only within the originating journal diff --git a/docs/decisions/http-api-boundary.md b/docs/decisions/http-api-boundary.md index 4e89be071..636433e24 100644 --- a/docs/decisions/http-api-boundary.md +++ b/docs/decisions/http-api-boundary.md @@ -48,9 +48,9 @@ The source audit on 2026-09-30 used `e0875269b21b707482545be9e94901acb897893b`. The public health mount precedes auth (`createHealthRoutes`, `packages/ui-server/src/app.ts:748`), the API guard follows public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:768`), and the -WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:950`). +WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:953`). Static serving is conditional (`if (options.staticRoot)`, -`packages/ui-server/src/app.ts:970-991`). That order establishes access; +`packages/ui-server/src/app.ts:973-994`). That order establishes access; it does not select compatibility status. A hermetic real-app launch, with temporary brain root, in-memory database, @@ -80,6 +80,15 @@ at response creation: existing SSE and journal owners still govern streaming response producers. A stalled client or producer cancellation cannot make shutdown wait for unbounded additional request bytes. +Module metadata for status and the modules list opts into caller and app-lifetime +admission cancellation. A queued lookup settles without launching after its +caller disconnects or app shutdown begins. An admitted execution still owns +its publication and cleanup. Status continues to omit unavailable optional +renderer metadata, while already completed results retain their ordinary +successful response. Other HTTP work, streaming producers and governed refresh +retain their existing lifetimes; the dispatch join still precedes state disposal. + + ## Versioning and changes Supported HTTP methods, paths, authorization requirements, accepted inputs, diff --git a/docs/decisions/runtime-requirements.md b/docs/decisions/runtime-requirements.md index 710bdbbb8..385eab245 100644 --- a/docs/decisions/runtime-requirements.md +++ b/docs/decisions/runtime-requirements.md @@ -194,7 +194,7 @@ Paths may appear in local diagnostics; public fixtures use temporary paths. | Requirement | Initial value | Evidence and limit of the claim | | --- | --- | --- | | Claude backend's SDK range | Retain `^0.3.241` | It is the published declaration (`"@anthropic-ai/claude-agent-sdk"`, `packages/ui-backend-claude/package.json:49`). Registry metadata on 2026-09-30 confirms 0.3.241 exists. This is a declared compatibility bound, not proof every allowed release passed current measurements. | -| ui-server's content CLI floor | Retain `0.33.0` | `--` support is the reason documented immediately above `MIN_BRAIN_CLI_VERSION` (`MIN_BRAIN_CLI_VERSION`, `packages/ui-server/src/brain/client.ts:100`); the initial boot refusal test used 0.32.9. The current test exercises the corrected floor (`a 0.40.0 brain repo pin`, `packages/ui-server/tests/brain-client.test.ts:242-254`). | +| ui-server's content CLI floor | Retain `0.33.0` | `--` support is the reason documented immediately above `MIN_BRAIN_CLI_VERSION` (`MIN_BRAIN_CLI_VERSION`, `packages/ui-server/src/brain/client.ts:101`); the initial boot refusal test used 0.32.9. The current test exercises the corrected floor (`a 0.40.0 brain repo pin`, `packages/ui-server/tests/brain-client.test.ts:242-254`). | | Bun engine floor | Retain existing `>=1.3.5` declarations | Existing engine metadata and the doctor's CVE-based warning (`const MIN_BUN`, `packages/core/src/cli/commands/doctor.ts:45`; `function checkRuntime`, `packages/core/src/cli/commands/doctor.ts:82-89`). This record introduces no new doctor verdict or blanket boot gate. | | Pi SDK requirements | Retain existing exact `0.87.1` declarations | `packages/ui-backend-pi/package.json`; a host floor composes with each actual imported Pi dependency's existing exact requirement. The Claude range ruling does not authorize changing Pi's pins. | | Additional default Claude runtime floor | None | There is no evidenced numerical incompatibility threshold in the existing record. The successful pairs are observations, not proof that the preceding release fails. A new library floor needs a feature requirement or reproducible incompatibility and its versioning review. | @@ -271,7 +271,7 @@ reciprocal host-version negotiation is involved. | Existing boundary | How this design uses it | | --- | --- | -| `probeBrainCliVersion` / `brainCliCommand` (`export async function probeBrainCliVersion`, `packages/ui-server/src/brain/client.ts:122-187`) | Reuse executable selection, wrapper, deadline and cleanup; add host floor composition, strict comparison and invocation verification without duplicating the 0.33.0 constant. | +| `probeBrainCliVersion` / `brainCliCommand` (`export async function probeBrainCliVersion`, `packages/ui-server/src/brain/client.ts:123-190`) | Reuse executable selection, wrapper, deadline and cleanup; add host floor composition, strict comparison and invocation verification without duplicating the 0.33.0 constant. | | `installedAgentSdkVersion` (`export function installedAgentSdkVersion`, `packages/ui-backend-claude/src/runtime-probe.ts:55-61`) | Retain importer-relative resolution; validate package name/version and the owner's manifest range. | | `selectedSpawn` / `probeClaudeRuntime` (`export function selectedSpawn`, `packages/ui-backend-claude/src/runtime-probe.ts:69-96`; `export async function probeClaudeRuntime`, `packages/ui-backend-claude/src/runtime-probe.ts:127-147`) | Keep SDK-selected command capture, native/JS override argv, environment, timeout and group cleanup; add strict identity validation and composed runtime floors. | | `probeBackendRuntimes` (`export async function probeBackendRuntimes`, `packages/ui-server/src/agent/backend.ts:392-439`) | Forward validated host requirements; retain active-backend selection and separate unmeasured-pair logging. | diff --git a/docs/decisions/session-principals.md b/docs/decisions/session-principals.md index f3f242611..26745b245 100644 --- a/docs/decisions/session-principals.md +++ b/docs/decisions/session-principals.md @@ -98,7 +98,7 @@ Both reviewers verified every row below against the source. | Global revoke | `auth.ts:321-330` | `setSetting` (overflow-guarded) + `clients.closeAll(1008, …)` | | Auth routes | `authRoutes`, `app.ts:754` | mounted **before** the guard at `authGuard(authMode`, `app.ts:768`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:799`) | | Passkey management | `passkeyManagementRoutes`, `app.ts:778` | mounted **after** the guard ("Mount AFTER the auth guard") | -| WS guard | `"/ws"`, `app.ts:950-967` | origin → `isWsAuthorized` (returns a boolean, `auth.ts:221-237`) → capacity → upgrade | +| WS guard | `"/ws"`, `app.ts:953-970` | origin → `isWsAuthorized` (returns a boolean, `auth.ts:221-237`) → capacity → upgrade | | WS upgrade | `ws/connection.ts:262-264` | `createWsUpgrade` ignores the request context | | WS admission | `ws/connection.ts:84` | `clients.add(ws)` — the socket's identity is unknown | | Turn record | `ws/run-session.ts:134-142` | recorder built from `{turnId, sessionId, billing}`; `RunningTurn` (`ws/turns.ts:55-77`) has no actor | diff --git a/docs/hosting/README.md b/docs/hosting/README.md index d7963554f..3ee77674d 100644 --- a/docs/hosting/README.md +++ b/docs/hosting/README.md @@ -44,7 +44,7 @@ the separately authorized cache measurement procedure. **The server refuses to boot against `@schlessera/brain` older than 0.42.0.** The floor is enforced at -(`MIN_BRAIN_CLI_VERSION`, `packages/ui-server/src/brain/client.ts:100`) and covers +(`MIN_BRAIN_CLI_VERSION`, `packages/ui-server/src/brain/client.ts:101`) and covers all commands and flags the server invokes, including 0.42.0’s scratch pin input. Hygiene review requires 0.41.0's selection, repair, recheck and fingerprint-bound dispositions; module settings and `stats --history` require 0.40.0. The command/flag inventory test guards the diff --git a/docs/integration-contract/package-api.md b/docs/integration-contract/package-api.md index 040340aed..a1dfb1bf3 100644 --- a/docs/integration-contract/package-api.md +++ b/docs/integration-contract/package-api.md @@ -3559,3 +3559,17 @@ the same identity is allowed; changing an established identity or patching a terminal span returns false without modifying the row. The recorder persists this identity in both the queryable session column and the existing attributes. This additive API ships in a minor release. + +## Module metadata admission cancellation (additive, #2457) + +`BrainClient.modules({ signal })` accepts an optional abort signal. It cancels +metadata waiting for execution admission, including a required CLI version +probe. Once an execution owns its admission locks, its worker, output +publication and cleanup drain normally. Cancellation between a version probe +and the module lookup prevents the latter from starting. Calls without an +option preserve their existing behavior and result shape. + +The internal host boundary carries a separate admission signal so existing +execution cancellation, including search worker termination and output drain, +keeps its semantics. The optional `BrainClient.modules` input is an additive +public TypeScript change and ships in a minor. diff --git a/docs/plans/storage-lifecycle.md b/docs/plans/storage-lifecycle.md index 0688f5a99..ba2c60b1b 100644 --- a/docs/plans/storage-lifecycle.md +++ b/docs/plans/storage-lifecycle.md @@ -31,7 +31,7 @@ Use maintenance workers for repository code, the CLI and Git. Host routes may perform narrowly validated descriptor operations, as the journal/revert do; they must not evaluate brain configuration, hooks, filters or executables. The existing brain client supplies boundary execution -(`async function execBrain`, `packages/ui-server/src/brain/client.ts:225-277`). +(`async function execBrain`, `packages/ui-server/src/brain/client.ts:228-282`). Do not turn the new surface into a host command runner or an extension seam. Reuse existing authorization. A requested reversible, fully journaled edit diff --git a/packages/ui-server/src/app.ts b/packages/ui-server/src/app.ts index a0a1f3b8d..7523f4617 100644 --- a/packages/ui-server/src/app.ts +++ b/packages/ui-server/src/app.ts @@ -787,11 +787,14 @@ export async function createApp(options: CreateAppOptions = {}): Promise brain.modules ? declaredContentRenderers((await brain.modules()).enabled) : undefined, + getContentRenderers: async signal => brain.modules ? declaredContentRenderers((await brain.modules({ signal: signal ? AbortSignal.any([signal, metadataClosing.signal]) : metadataClosing.signal })).enabled) : undefined, getCronStatus: () => cron.getCronStatus(), isTurnActive: () => host.coordinator.isTurnActive(), // Undefined when the injected consumer cannot be read back (a real OTel @@ -924,7 +927,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise void | Promise) => { try { await close(); } catch (error) { failures.push(error); } }; for (const cancelBody of httpRequests.values()) cancelBody(); diff --git a/packages/ui-server/src/brain/client.ts b/packages/ui-server/src/brain/client.ts index 453823d1d..35924b38e 100644 --- a/packages/ui-server/src/brain/client.ts +++ b/packages/ui-server/src/brain/client.ts @@ -53,7 +53,8 @@ export interface BrainClient { /** argv prefix for invoking the brain CLI inside the repo. */ cliCommand(): string[]; /** Additive settings support; older injected clients may omit it. */ - modules?(): Promise<{ enabled: ConfiguredModule[]; available: unknown[] }>; + /** Cancellation applies before execution admission; admitted output and cleanup drain normally. */ + modules?(opts?: { signal?: AbortSignal }): Promise<{ enabled: ConfiguredModule[]; available: unknown[] }>; moduleSettings?(name: string, operation: "get" | "save" | "validate" | "preview" | "migrate" | "enable" | "disable" | "action", options?: { values?: Record; revision?: string; action?: string }): Promise; search( query: string, @@ -121,7 +122,7 @@ export function brainCliCommand(brainPath: string): string[] { /** Probe the selected content CLI, keeping unknown versions warning-only by default. */ export async function probeBrainCliVersion(brainPath: string, log: Logger, options: { exec: ExecWrapperConfig; minimumVersion?: string; phase?: string; command?: readonly string[]; signal?: AbortSignal; /** @internal Where the CLI runs: in a maintenance worker while the host execution boundary holds (#1648). */ - boundary?: HostBoundary; outputBytes?: number; assertAuthorized?: () => void }): Promise { + boundary?: HostBoundary; admissionSignal?: AbortSignal; outputBytes?: number; assertAuthorized?: () => void }): Promise { const phase = options.phase ?? "startup"; const requirements = [{ owner: "@schlessera/brain-ui-server", kind: "minimum" as const, declaration: MIN_BRAIN_CLI_VERSION }]; if (options.minimumVersion !== undefined) { @@ -139,10 +140,11 @@ export async function probeBrainCliVersion(brainPath: string, log: Logger, optio let result: VersionProbeResult; const boundary = options.boundary ?? createHostBoundary({ brainPath, exec: options.exec }); try { + options.admissionSignal?.throwIfAborted(); if (boundary.isolated()) { // The CLI is the brain's own code: answered from a maintenance worker. const answer = await boundary.run({ argv: [...command, "--version"], env: subprocessEnv("brainCli", { NO_COLOR: "1" }), - credentials: false, gate: "shared", purpose: "brain CLI version", ...(options.signal ? { signal: options.signal } : {}), + credentials: false, gate: "shared", purpose: "brain CLI version", admissionSignal: options.admissionSignal, ...(options.signal ? { signal: options.signal } : {}), timeoutMs: VERSION_PROBE_TIMEOUT_MS, ...(options.outputBytes ? { outputBytes: options.outputBytes } : {}), ...(options.assertAuthorized ? { assertAuthorized: options.assertAuthorized } : {}) }); result = { stdout: answer.stdout, stderr: answer.stderr, exitCode: answer.timedOut ? null : answer.exitCode, timedOut: answer.timedOut === true, cleanupWarnings: [] }; @@ -156,6 +158,7 @@ export async function probeBrainCliVersion(brainPath: string, log: Logger, optio } } catch (error) { options.signal?.throwIfAborted(); + options.admissionSignal?.throwIfAborted(); unknown(error instanceof Error ? error.message : String(error), "brain CLI version probe failed; continuing"); return; } @@ -222,14 +225,15 @@ export function createBrainClient(opts: BrainClientOptions): BrainClient { throw new Error("searchTimeoutMs must be a positive finite number"); } - async function execBrain(args: string[], signal?: AbortSignal, input?: string, lock?: import("@schlessera/brain-ui-sdk/server").KeyedLock, maintenance?: MaintenanceRequest & { minimumVersion?: string }): Promise { + async function execBrain(args: string[], signal?: AbortSignal, input?: string, lock?: import("@schlessera/brain-ui-sdk/server").KeyedLock, maintenance?: MaintenanceRequest & { minimumVersion?: string; admissionSignal?: AbortSignal }): Promise { signal?.throwIfAborted(); + maintenance?.admissionSignal?.throwIfAborted(); if (maintenance?.bounded && !boundary.isolated()) throw new BrainMaintenanceUnavailable("Storage maintenance requires a qualified worker before probing repository code."); const command = brainCliCommand(brainPath); const requiredVersion = maintenance?.minimumVersion ?? minimumVersion; if (requiredVersion !== undefined) { - try { await probeBrainCliVersion(brainPath, log, { exec, minimumVersion: requiredVersion, phase: "invocation", command, signal, boundary, ...(maintenance?.bounded ? { outputBytes: 1024 * 1024 } : {}), ...(maintenance?.assertAuthorized ? { assertAuthorized: maintenance.assertAuthorized } : {}) }); } - catch (error) { signal?.throwIfAborted(); if (maintenance?.minimumVersion) throw new BrainMaintenanceUnavailable("Storage packing requires a qualified core CLI at " + maintenance.minimumVersion + " or newer.", "core_upgrade_or_probe_required"); throw error; } + try { await probeBrainCliVersion(brainPath, log, { exec, minimumVersion: requiredVersion, phase: "invocation", command, signal, boundary, admissionSignal: maintenance?.admissionSignal, ...(maintenance?.bounded ? { outputBytes: 1024 * 1024 } : {}), ...(maintenance?.assertAuthorized ? { assertAuthorized: maintenance.assertAuthorized } : {}) }); } + catch (error) { signal?.throwIfAborted(); maintenance?.admissionSignal?.throwIfAborted(); if (maintenance?.minimumVersion) throw new BrainMaintenanceUnavailable("Storage packing requires a qualified core CLI at " + maintenance.minimumVersion + " or newer.", "core_upgrade_or_probe_required"); throw error; } signal?.throwIfAborted(); } // Every brain CLI launch goes through the host boundary, not only the @@ -248,12 +252,13 @@ export function createBrainClient(opts: BrainClientOptions): BrainClient { credentials, gate, purpose: `brain ${args.slice(0, 2).join(" ")}`, + admissionSignal: maintenance?.admissionSignal, ...(args[0] === "sync" ? { sync: true } : {}), ...(lock ? { lock: { keyed: lock, key: "brain-docs" } } : {}), ...(maintenance?.assertAuthorized ? { assertAuthorized: maintenance.assertAuthorized } : {}), ...(maintenance?.bounded ? { outputBytes: 1024 * 1024, timeoutMs: 5 * 60_000, onExecute: () => { entered = true; } } : {}), ...(input !== undefined ? { stdin: input } : {}), - // Only read-only search passes a signal. Kill even a CLI that ignores + // Execution cancellation kills even a CLI that ignores // SIGTERM; abandoning a request must not leave the local process alive. ...(signal ? { signal, killSignal: "SIGKILL" as const } : {}), }); } catch (error) { @@ -281,8 +286,8 @@ export function createBrainClient(opts: BrainClientOptions): BrainClient { return brainCliCommand(brainPath); }, - async modules() { - return parseJsonOutput(await execBrain(["module", "list", "--json"])); + async modules(options) { + return parseJsonOutput(await execBrain(["module", "list", "--json"], undefined, undefined, undefined, { admissionSignal: options?.signal })); }, async moduleSettings(name, operation, options = {}) { diff --git a/packages/ui-server/src/brain/host-boundary.ts b/packages/ui-server/src/brain/host-boundary.ts index 0cb3a0a88..210c96997 100644 --- a/packages/ui-server/src/brain/host-boundary.ts +++ b/packages/ui-server/src/brain/host-boundary.ts @@ -183,7 +183,10 @@ export interface HostBoundary { /** Both, as `/api/status` reports them. */ status(): GovernedStatus; /** Runs one command to completion, inside a maintenance worker while the boundary is in force. */ - run(execution: BrainExecution): Promise; + run(execution: BrainExecution & { + /** Cancel only before execution admission; a granted owner drains publication and cleanup. */ + admissionSignal?: AbortSignal; + }): Promise; /** The same, with live output, for a streamed sync or a cron job. */ launch(execution: BrainExecution): Promise; /** A short synchronous query (git `check-ignore`). Takes no gate. */ @@ -494,13 +497,17 @@ export function createHostBoundary(options: HostBoundaryOptions): HostBoundary { }, withGate, async run(execution) { - execution.signal?.throwIfAborted(); + const admissionSignal = execution.admissionSignal + ? execution.signal ? AbortSignal.any([execution.signal, execution.admissionSignal]) : execution.admissionSignal + : execution.signal; + const admission = { ...execution, signal: admissionSignal }; + admissionSignal?.throwIfAborted(); execution.assertAuthorized?.(); - return gated(execution, async hold => { - execution.signal?.throwIfAborted(); + return gated(admission, async hold => { + admissionSignal?.throwIfAborted(); if (!isolated()) { - const result = await credentialed(execution, async () => { - execution.signal?.throwIfAborted(); + const result = await credentialed(admission, async () => { + admissionSignal?.throwIfAborted(); // Another shared turn can establish sticky worker history while // credential exclusion waits. Release credentials before rerouting. if (isolated()) return undefined; @@ -525,10 +532,11 @@ export function createHostBoundary(options: HostBoundaryOptions): HostBoundary { }); if (result) return result; } - return credentialed(execution, async () => { + return credentialed(admission, async () => { if (execution.sync) return outputLock.withLock(brain, () => runMirrorSync({ log: options.log, nativeManager: options.nativeManager, folderPaths: hold?.folderPaths, command: command(execution), transportEnv: execution.env, mirror: mirror ??= syncMirrorPath(brain, options.dbPath), - ...(execution.signal ? { signal: execution.signal } : {}), ...(execution.timeoutMs ? { timeoutMs: execution.timeoutMs } : {}) }), { signal: execution.signal }); + ...(execution.signal ? { signal: execution.signal } : {}), ...(execution.timeoutMs ? { timeoutMs: execution.timeoutMs } : {}) }), { signal: admissionSignal }); const run = async () => { + admissionSignal?.throwIfAborted(); execution.assertAuthorized?.(); const view = await prepareInput(execution.signal); let outputs: Awaited> | undefined; @@ -550,7 +558,7 @@ export function createHostBoundary(options: HostBoundaryOptions): HostBoundary { } finally { try { await outputs?.cleanup(); } finally { await view.cleanup(); } } }; return outputLock.withLock(brain, () => execution.lock - ? execution.lock.keyed.withLock(execution.lock.key, run, { signal: execution.signal }) : run(), { signal: execution.signal }); + ? execution.lock.keyed.withLock(execution.lock.key, run, { signal: admissionSignal }) : run(), { signal: admissionSignal }); }, execution.sync === true); }); }, diff --git a/packages/ui-server/src/routes/health.ts b/packages/ui-server/src/routes/health.ts index 978d55980..8490110ba 100644 --- a/packages/ui-server/src/routes/health.ts +++ b/packages/ui-server/src/routes/health.ts @@ -39,7 +39,7 @@ export function createHealthRoutes(deps: { db: Database }): Hono { export interface StatusDeps { /** Trusted metadata from configured modules; absent with an older CLI. */ - getContentRenderers?(): Promise; + getContentRenderers?(signal?: AbortSignal): Promise; /** Git SHA baked at build time (SOURCE_COMMIT), "dev" when unset. */ sourceCommit: string; getCronStatus(): unknown; @@ -60,7 +60,7 @@ export interface StatusDeps { export function createStatusRoutes(deps: StatusDeps): Hono { return new Hono().get("/status", async (c) => { const subscription = deps.getSubscription ? await deps.getSubscription() : undefined; - const contentRenderers = await deps.getContentRenderers?.().catch(() => undefined); + const contentRenderers = await deps.getContentRenderers?.(c.req.raw.signal).catch(() => undefined); return c.json({ ...(contentRenderers !== undefined ? { contentRenderers } : {}), healthy: true, diff --git a/packages/ui-server/src/routes/modules.ts b/packages/ui-server/src/routes/modules.ts index dde18eff7..0535846cb 100644 --- a/packages/ui-server/src/routes/modules.ts +++ b/packages/ui-server/src/routes/modules.ts @@ -4,12 +4,12 @@ import { readJsonBody } from "../middleware/body-limit.js"; import { requireJson } from "../middleware/origin.js"; /** Mount behind the same principal/origin guards as the other Settings writers. */ -export function createModuleRoutes(brain: BrainClient): Hono { +export function createModuleRoutes(brain: BrainClient, closing?: AbortSignal): Hono { const app = new Hono(); const valid = (name: string) => /^[a-z][a-z0-9-]{0,30}$/.test(name); app.get("/modules", async (c) => { if (!brain.modules) return c.json({ error: "Module settings are unavailable with this brain CLI" }, 501); - try { return c.json(await brain.modules()); } + try { return c.json(await brain.modules({ signal: closing ? AbortSignal.any([c.req.raw.signal, closing]) : c.req.raw.signal })); } catch (error) { return c.json({ error: (error as Error).message }, 500); } }); app.get("/modules/:name/settings", async (c) => { diff --git a/packages/ui-server/tests/app-metadata-cancellation.test.ts b/packages/ui-server/tests/app-metadata-cancellation.test.ts new file mode 100644 index 000000000..aea0b860d --- /dev/null +++ b/packages/ui-server/tests/app-metadata-cancellation.test.ts @@ -0,0 +1,193 @@ +import { expect, spyOn, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { workerIdentityHelper } from "@schlessera/brain-ui-sdk/internal"; +import type { KeyedLock } from "@schlessera/brain-ui-sdk/server"; +import * as maintenanceOutput from "../src/brain/maintenance-output.js"; +import * as maintenanceWorker from "../../ui-sdk/src/server/maintenance-worker.js"; +import { CLI_LOG, httpContractApp } from "./helpers/http-contract-app.js"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} + +test("admitted metadata drains its real worker, publication and cleanup during shutdown", async () => { + const fixture = await httpContractApp(); + const symbol = Symbol.for("@schlessera/brain-ui-server/maintenance-output-lock"); + const lock = (globalThis as unknown as Record)[symbol]!; + const key = realpathSync(fixture.brainPath); + const launched = deferred(), inputRelease = deferred(), publishing = deferred(), publishRelease = deferred(); + const cleaning = deferred(), cleanupRelease = deferred(); + const events: string[] = []; + let pending: Promise | undefined, closing: Promise | undefined; + let pid = 0, start = ""; + const originalLaunch = maintenanceWorker.launchMaintenanceWorker; + const launch = spyOn(maintenanceWorker, "launchMaintenanceWorker").mockImplementation(input => { + expect(workerIdentityHelper(), "ACTIVE_CONTROL_USES_ORDINARY_NATIVE_LAUNCHER").toBeUndefined(); + const worker = originalLaunch(input); + pid = worker.pid; + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + start = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]!; + events.push("launched"); + const eof = (name: string) => new TransformStream({ + transform(chunk, controller) { controller.enqueue(chunk); }, flush() { events.push(name); }, + }); + launched.resolve(); + return { ...worker, + stdout: worker.stdout.pipeThrough(eof("stdout EOF")), + stderr: worker.stderr.pipeThrough(eof("stderr EOF")), + exited: worker.exited.then(code => { events.push(`exit ${code}`); return code; }), + async input(data) { await inputRelease.promise; await worker.input(data); events.push("stdin complete"); }, + }; + }); + const originalOutput = maintenanceOutput.prepareMaintenanceOutput; + const output = spyOn(maintenanceOutput, "prepareMaintenanceOutput").mockImplementation(async (...args) => { + const prepared = await originalOutput(...args); + return { ...prepared, + async publish() { + events.push("publication entered"); publishing.resolve(); await publishRelease.promise; + await prepared.publish(); events.push("published"); + }, + async cleanup() { + events.push("cleanup entered"); cleaning.resolve(); await cleanupRelease.promise; + await prepared.cleanup(); events.push("cleaned"); + }, + }; + }); + try { + pending = Promise.resolve(fixture.fetch("/api/status")); + await launched.promise; + expect(pid).toBeGreaterThan(0); + expect(() => process.kill(pid, 0), "ORDINARY_NATIVE_WORKER_EXISTS_AT_CLOSE").not.toThrow(); + expect(events).not.toContain("stdin complete"); + closing = fixture.app.close(); + expect(lock.heldKeys, "ACTIVE_OWNER_RETAINS_OUTPUT_LOCK").toContain(key); + expect(() => fixture.app.db.query("SELECT 1").get()).not.toThrow(); + inputRelease.resolve(); + const publishes = await Promise.race([publishing.promise.then(() => true), cleaning.promise.then(() => false)]); + expect(publishes, "ADMITTED_METADATA_REACHES_PUBLICATION_AFTER_CLOSE").toBe(true); + for (const event of ["exit 0", "stdout EOF", "stderr EOF", "stdin complete"]) expect(events).toContain(event); + expect(lock.heldKeys, "LOCK_HELD_THROUGH_PUBLICATION").toContain(key); + expect(() => fixture.app.db.query("SELECT 1").get()).not.toThrow(); + expect(existsSync(join(fixture.brainPath, CLI_LOG))).toBe(false); + publishRelease.resolve(); + await cleaning.promise; + expect(readFileSync(join(fixture.brainPath, CLI_LOG), "utf8")).toContain('"module","list"'); + expect(lock.heldKeys, "LOCK_HELD_THROUGH_CLEANUP").toContain(key); + expect(() => fixture.app.db.query("SELECT 1").get()).not.toThrow(); + cleanupRelease.resolve(); + const response = await pending; + expect(response.status).toBe(200); + expect(await response.json()).toHaveProperty("contentRenderers", []); + await closing; + expect(events).toContain("cleaned"); + expect(lock.heldKeys).not.toContain(key); + expect(() => fixture.app.db.query("SELECT 1").get()).toThrow(); + console.info("METADATA_ACTIVE_OWNER", JSON.stringify({ pid, start, events })); + } finally { + inputRelease.resolve(); publishRelease.resolve(); cleanupRelease.resolve(); + await pending?.catch(() => {}); await closing?.catch(() => {}); + launch.mockRestore(); output.mockRestore(); + await fixture.close(); + } +}); + +for (const path of ["/api/status", "/api/modules"] as const) { +for (const trigger of ["caller abort", "app close", "loopback abort", "pre-aborted"] as const) { + test(`queued metadata ${path} cancels before output-lock release on ${trigger}`, async () => { + const state = mkdtempSync(join(tmpdir(), "odysseus-metadata-cancel-")); + let fixture: Awaited> | undefined; + let release: (() => void) | undefined; + let restore: (() => void) | undefined; + let pending: Promise | undefined, closing: Promise | undefined; + let timer: ReturnType | undefined; + let server: ReturnType | undefined; + let network: Promise | undefined; + const disconnected = deferred(); + let serverAborted = false; + const arrived = deferred(), caller = new AbortController(); + let bodyEntries = 0, holderReleased = false; + try { + fixture = await httpContractApp({ env: { DB_PATH: join(state, "ui.sqlite") } }); + const key = realpathSync(fixture.brainPath); + const symbol = Symbol.for("@schlessera/brain-ui-server/maintenance-output-lock"); + const lock = (globalThis as unknown as Record)[symbol]!; + expect(lock).toBeDefined(); + release = await lock.acquire(key); + const descriptor = Object.getOwnPropertyDescriptor(lock, "withLock")!; + const original = lock.withLock; + lock.withLock = function(target: string, fn: () => T | Promise, opts?: Parameters[1]) { + const result = original.call(this, target, target === key ? () => { + bodyEntries++; + return fn(); + } : fn, opts) as Promise; + if (target === key) { + console.info("METADATA_LOCK_ARRIVAL", JSON.stringify({ path, trigger, signal: Boolean(opts?.signal), aborted: opts?.signal?.aborted ?? false })); + arrived.resolve(); + } + return result; + }; + restore = () => Object.defineProperty(lock, "withLock", descriptor); + if (trigger === "pre-aborted") caller.abort(); + if (trigger === "loopback abort") { + server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { + request.signal.addEventListener("abort", () => { serverAborted = true; disconnected.resolve(); }, { once: true }); + pending = Promise.resolve(fixture!.app.fetch(request)); + return pending; + } }); + network = fetch(`http://127.0.0.1:${server.port}${path}`, { signal: caller.signal }).then( + () => { throw Error("Cancelled loopback request unexpectedly completed"); }, + error => { expect(error.name).toBe("AbortError"); }, + ); + } else pending = Promise.resolve(fixture.fetch(path, { signal: caller.signal })); + if (trigger !== "pre-aborted") await arrived.promise; + expect(bodyEntries, "QUEUED_STATUS_HAS_NOT_ENTERED_OUTPUT_BODY").toBe(0); + expect(() => fixture!.app.db.query("SELECT 1").get(), "HTTP_OWNER_ALIVE_UNTIL_HANDLER_SETTLES").not.toThrow(); + if (trigger === "caller abort" || trigger === "loopback abort") caller.abort(); + else if (trigger === "app close") { + closing = fixture.app.close(); + expect((await fixture.fetch("/api/health")).status, "CLOSING_APP_REFUSES_NEW_HTTP_DISPATCH").toBe(503); + } + const outcome = await Promise.race([ + pending!.then(response => response.status), + new Promise<"still queued">((resolve) => { timer = setTimeout(() => resolve("still queued"), 1500); }), + ]); + clearTimeout(timer); + console.info("METADATA_QUEUE_ORACLE", JSON.stringify({ path, trigger, outcome, holderReleased, bodyEntries })); + expect(outcome, "QUEUED_STATUS_SETTLES_BEFORE_OUTPUT_LOCK_RELEASE").not.toBe("still queued"); + expect(outcome).toBe(path === "/api/status" ? 200 : 500); + if (trigger === "loopback abort") { + expect(serverAborted, "ACTUAL_BUN_SERVER_REQUEST_ABORT_OBSERVED").toBe(true); + await disconnected.promise; + } + expect(holderReleased).toBe(false); + expect(lock.heldKeys).toContain(key); + expect(bodyEntries).toBe(0); + release(); holderReleased = true; + // A FIFO sentinel proves no abandoned operation can start behind us. + await original.call(lock, key, () => {}); + await pending; + await closing; + expect(bodyEntries, "CANCELLED_STATUS_NEVER_STARTS_AFTER_LOCK_RELEASE").toBe(0); + const log = join(fixture.brainPath, CLI_LOG); + const commands = existsSync(log) ? readFileSync(log, "utf8") : ""; + expect(commands).not.toContain('"module","list"'); + } finally { + clearTimeout(timer); + release?.(); holderReleased = true; + await pending?.catch(() => {}); + await closing?.catch(() => {}); + await network; + server?.stop(true); + restore?.(); + await fixture?.close(); + rmSync(state, { recursive: true, force: true }); + console.info("METADATA_QUEUE_CLEANUP_JOINED", trigger); + } + }); +} + +} From f8c97e1ba27025e6cbb6cdbb7d479c94ff5861f1 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Wed, 21 Oct 2026 03:00:10 +0200 Subject: [PATCH 2/4] test: align owned-prune fixture with admission gate --- scripts/native-proof/owned-prune-variant.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/native-proof/owned-prune-variant.ts b/scripts/native-proof/owned-prune-variant.ts index 1e1aeddfa..553831690 100644 --- a/scripts/native-proof/owned-prune-variant.ts +++ b/scripts/native-proof/owned-prune-variant.ts @@ -20,7 +20,7 @@ export function ownedPruneVariant(cronSource: string, boundarySource: string) { const runStart = boundary.indexOf(" async run(execution) {"), runEnd = boundary.indexOf(" async launch(execution) {", runStart); if (runStart < 0 || runEnd < 0) throw Error("Owned prune fixture run boundary changed"); let run = boundary.slice(runStart, runEnd); - run = replace("boundary", run, " return gated(execution, async hold => {", ' return gated(execution, async hold => {\n fixtureStage("gate:admitted");'); + run = replace("boundary", run, " return gated(admission, async hold => {", ' return gated(admission, async hold => {\n fixtureStage("gate:admitted");'); run = replace("boundary", run, " const run = async () => {", ' const run = async () => {\n fixtureStage("output-lock:admitted");\n fixtureStage("view:start");'); const mark = (before: string, start: string, done: string) => { run = replace("boundary", run, before, ` fixtureStage("${start}");\n${before}\n fixtureStage("${done}");`); }; run = replace("boundary", run, " const view = await prepareInput(execution.signal);", ' const view = await prepareInput(execution.signal);\n fixtureStage("view:done");'); From dd4a6c1990b623739e4483adeca3ddf469749fb4 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Thu, 22 Oct 2026 10:38:26 +0200 Subject: [PATCH 3/4] test(ui-react): report external-speech teardown timing The loaded rerun #2473 asks for records app.close() timing. The afterAll hook now writes the browser and app.close() durations to stderr next to its existing progress lines; assertions and deadlines are unchanged. Refs #2457 Co-Authored-By: Claude Opus 5.5 --- packages/ui-react/tests/external-speech-runtime.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui-react/tests/external-speech-runtime.test.ts b/packages/ui-react/tests/external-speech-runtime.test.ts index a7a8a830b..ee3149820 100644 --- a/packages/ui-react/tests/external-speech-runtime.test.ts +++ b/packages/ui-react/tests/external-speech-runtime.test.ts @@ -80,7 +80,14 @@ if (worker) { browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage", "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"] }); }, 180_000); - afterAll(async () => { await browser?.close(); server?.stop(true); speechServer?.stop(true); await app?.close(); }); + afterAll(async () => { + const started = performance.now(); + await browser?.close(); server?.stop(true); speechServer?.stop(true); + // The #2457 receipt: app.close() must not wait on queued status metadata. + const closing = performance.now(); + await app?.close(); + process.stderr.write(`[external-speech] teardown: browser ${(closing - started).toFixed(1)} ms, app.close() ${(performance.now() - closing).toFixed(1)} ms\n`); + }); } async function open(proveBackend = false): Promise { From 654e2bdf27ee983d8ab1327e7c35461bd4711e0c Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Thu, 22 Oct 2026 11:08:50 +0200 Subject: [PATCH 4/4] test(ui-server): assert cancelled status omits renderer metadata The queued-metadata matrix checked only status codes. It now also reads the response body: cancelled /api/status omits contentRenderers and cancelled /api/modules carries an error. The loopback case is skipped because its body already went to the client. Refs #2457 Co-Authored-By: Claude Opus 5.5 --- packages/ui-server/tests/app-metadata-cancellation.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui-server/tests/app-metadata-cancellation.test.ts b/packages/ui-server/tests/app-metadata-cancellation.test.ts index e79d482c1..d83efe286 100644 --- a/packages/ui-server/tests/app-metadata-cancellation.test.ts +++ b/packages/ui-server/tests/app-metadata-cancellation.test.ts @@ -159,6 +159,12 @@ for (const trigger of ["caller abort", "app close", "loopback abort", "pre-abort console.info("METADATA_QUEUE_ORACLE", JSON.stringify({ path, trigger, outcome, holderReleased, bodyEntries })); expect(outcome, "QUEUED_STATUS_SETTLES_BEFORE_OUTPUT_LOCK_RELEASE").not.toBe("still queued"); expect(outcome).toBe(path === "/api/status" ? 200 : 500); + // The loopback server has already streamed its response to the client. + if (trigger !== "loopback abort") { + const body = await (await pending!).json() as Record; + if (path === "/api/status") expect(body, "CANCELLED_STATUS_OMITS_RENDERERS").not.toHaveProperty("contentRenderers"); + else expect(body).toHaveProperty("error"); + } if (trigger === "loopback abort") { expect(serverAborted, "ACTUAL_BUN_SERVER_REQUEST_ABORT_OBSERVED").toBe(true); await disconnected.promise;