diff --git a/src/backends/native-authored-artifact-error.ts b/src/backends/native-authored-artifact-error.ts new file mode 100644 index 000000000..e18a4c7c3 --- /dev/null +++ b/src/backends/native-authored-artifact-error.ts @@ -0,0 +1,404 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { types } from "node:util"; +import { + NativeComposeGenerationError, + NativeComposeInspectionError, +} from "../lib/native-compose-private-state.ts"; +import { LifecycleSessionPresenceUnknownError } from "../lib/project-lifecycle-sessions.ts"; +import { MuxCommandTimeoutError } from "../mux/mux-backend.ts"; + +export type NativeAuthoredArtifactPhase = + | "scope" + | "directories" + | "private-store" + | "action"; +type ArtifactCause = + | "artifact_refused" + | "lock_busy" + | "lock_inspection_timeout" + | "lock_inspection_failed" + | "lock_inspection_malformed" + | "mux_timeout" + | "mux_presence_unknown" + | "host_deadline" + | "io_failed" + | "action_failed"; +/** Closed errno names. Anything else reports no system code, never its raw value. */ +const SYSTEM_CODES = new Set([ + "E2BIG", + "EACCES", + "EADDRINUSE", + "EADDRNOTAVAIL", + "EAGAIN", + "EBADF", + "EBUSY", + "ECANCELED", + "ECHILD", + "ECONNREFUSED", + "ECONNRESET", + "EEXIST", + "EFAULT", + "EFBIG", + "EINTR", + "EINVAL", + "EIO", + "EISDIR", + "ELOOP", + "EMFILE", + "EMLINK", + "ENAMETOOLONG", + "ENFILE", + "ENODEV", + "ENOENT", + "ENOEXEC", + "ENOMEM", + "ENOSPC", + "ENOTCONN", + "ENOTDIR", + "ENOTEMPTY", + "ENOTSOCK", + "ENOTSUP", + "ENXIO", + "EOPNOTSUPP", + "EPERM", + "EPIPE", + "EROFS", + "ESPIPE", + "ESRCH", + "ETIMEDOUT", + "ETXTBSY", + "EXDEV", +]); +type ErrorClass = + | "TypeError" + | "RangeError" + | "SyntaxError" + | "ReferenceError" + | "AggregateError" + | "DOMException" + | "Error" + | "non_error"; +const ERROR_CLASSES: readonly [ + abstract new (...args: never[]) => unknown, + ErrorClass, +][] = [ + [TypeError, "TypeError"], + [RangeError, "RangeError"], + [SyntaxError, "SyntaxError"], + [ReferenceError, "ReferenceError"], + [AggregateError, "AggregateError"], + [DOMException, "DOMException"], + [Error, "Error"], +]; +/** Source frames kept per failure; enough for a refusal helper and its callers. */ +const MAX_SITES = 4; +/** A V8/JSC frame line only: message text and other lines never count as frames. */ +const SOURCE_FRAME = + /^ {4}at (?:.{0,200} \()?([^\s()]{1,400}\.ts):(\d{1,6}):\d{1,6}\)?$/; +/** Bounds before any matching, so a huge message cannot stall failure handling. */ +const MAX_STACK_CHARS = 16_384; +const MAX_FRAME_CHARS = 640; +const MAX_SOURCE_FILES = 8192; +/** This checkout's `src/`; frames outside it (tests, dependencies, a bundle) are dropped. */ +const SOURCE_ROOT = `${dirname(import.meta.dir)}/`; +const SITE_PATH = /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*\.ts$/; +const INSPECTION_CAUSES = { + timeout: "lock_inspection_timeout", + failed: "lock_inspection_failed", + malformed: "lock_inspection_malformed", +} as const; +export type NativeAuthoredArtifactDiagnostic = Readonly<{ + phase: NativeAuthoredArtifactPhase; + cause_code: ArtifactCause; + inspection: "boot-session" | "process-birth" | null; + elapsed_ms: number | null; + system_code: string | null; + /** Built-in class of the thrown value; subclasses report their nearest built-in. */ + error_class: ErrorClass | null; + /** + * Innermost Hack source frames (`backends/file.ts:line`, relative to `src/`). + * A fixed source location, never a path prefix, message or value. Empty when the + * error carries no own stack (native fs errors) or runs from a compiled bundle. + */ + sites: readonly string[]; +}>; +type Diagnostic = NativeAuthoredArtifactDiagnostic; +const issuedDiagnostics = new WeakMap(); +const diagnosticObservers = new AsyncLocalStorage< + (diagnostic: Diagnostic) => void +>(); + +/** Observe only closed metadata before an outer startup boundary replaces an error. */ +export function observeNativeAuthoredArtifactErrors(opts: { + readonly onDiagnostic: (diagnostic: Diagnostic) => void; + readonly action: () => T; +}): T { + return diagnosticObservers.run(opts.onDiagnostic, opts.action); +} + +/** A caller's monotonic host-operation budget expired; carries no callback data. */ +export class NativeAuthoredHostDeadlineError extends Error { + constructor() { + super("Native authored host operation expired; values omitted."); + } +} + +function closedFailure(diagnostic: Diagnostic): NativeAuthoredArtifactError { + const failure = new NativeAuthoredArtifactError(diagnostic); + issuedDiagnostics.set(failure, failure.diagnostic); + try { + const observed: unknown = diagnosticObservers.getStore()?.( + failure.diagnostic + ); + void Promise.resolve(observed).catch(() => undefined); + } catch { + // Optional diagnostics never change admission or recovery outcomes. + } + return failure; +} + +/** Fixed refusal plus closed diagnostic fields; raw child, callback and filesystem errors stay private. */ +export class NativeAuthoredArtifactError extends Error { + readonly diagnostic: Diagnostic; + constructor(diagnostic: Diagnostic) { + super( + "Native authored run artifact is unsafe, changed, or owned by another run; inspect native state before retrying. Values omitted." + ); + this.diagnostic = Object.freeze({ + ...diagnostic, + sites: Object.freeze([...diagnostic.sites]), + }); + } +} + +/** Preserve the innermost diagnostic without retaining an arbitrary error, stack or path as its cause. */ +export function nativeAuthoredArtifactError( + error: unknown, + phase: NativeAuthoredArtifactPhase +): NativeAuthoredArtifactError { + try { + return classifyArtifactError(error, phase); + } catch { + // Even a rejected proxy's prototype/descriptor traps remain private. + return closedFailure({ + phase, + cause_code: "action_failed", + inspection: null, + elapsed_ms: null, + system_code: null, + error_class: null, + sites: [], + }); + } +} + +/** + * Whether a value or anything on its prototype chain is a proxy. Checked before any + * reflection or `instanceof`, which would otherwise run its traps. `types.isProxy` + * and `Object.getPrototypeOf` on a non-proxy run no user code. + */ +function involvesProxy(value: object): boolean { + let current: object | null = value; + for (let depth = 0; current !== null; depth += 1) { + if (depth === 64 || types.isProxy(current)) { + return true; + } + current = Object.getPrototypeOf(current) as object | null; + } + return false; +} + +/** One read of an own data property; accessors are never invoked. Callers exclude proxies. */ +function ownData(value: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; +} + +function errorClass(error: unknown): ErrorClass { + for (const [builtIn, name] of ERROR_CLASSES) { + if (error instanceof builtIn) { + return name; + } + } + return "non_error"; +} + +let sourceInventory: ReadonlySet | undefined; +/** + * Hack's own `src/` files, relative to it, read once. A site must name one of them, + * so a forged stack can only point at a real source file, never carry other text. + * Empty in a compiled bundle, where frames name no checkout files. + */ +function sources(): ReadonlySet { + if (sourceInventory) { + return sourceInventory; + } + const found = new Set(); + const pending = [""]; + try { + while (pending.length > 0 && found.size < MAX_SOURCE_FILES) { + const relative = pending.pop() ?? ""; + for (const entry of readdirSync(join(SOURCE_ROOT, relative), { + withFileTypes: true, + })) { + const path = relative === "" ? entry.name : `${relative}/${entry.name}`; + if (entry.isDirectory()) { + pending.push(path); + } else if (entry.isFile() && SITE_PATH.test(path)) { + found.add(path); + } + } + } + } catch { + // No readable source tree: no sites. + } + sourceInventory = found; + return found; +} + +/** + * Hack source locations relative to `src/`, from frame lines only, naming files in + * the source inventory. The checkout prefix, message and any other text never leave. + */ +function sourceSites(error: object): string[] { + const stack = ownData(error, "stack"); + if (typeof stack !== "string") { + return []; + } + const sites: string[] = []; + for (const line of stack.slice(0, MAX_STACK_CHARS).split("\n", 64)) { + if (line.length > MAX_FRAME_CHARS) { + continue; + } + const match = SOURCE_FRAME.exec(line); + const path = match?.[1]; + if (!(path?.startsWith(SOURCE_ROOT) && match?.[2])) { + continue; + } + const relative = path.slice(SOURCE_ROOT.length); + if (!sources().has(relative)) { + continue; + } + sites.push(`${relative}:${match[2]}`); + if (sites.length === MAX_SITES) { + break; + } + } + return sites; +} + +/** Each field is read once as an own data property; a subclass getter yields null. */ +function inspectionFields( + error: NativeComposeInspectionError +): Pick { + const outcome = ownData(error, "outcome"); + const observation = ownData(error, "observation"); + const elapsed = ownData(error, "elapsedMs"); + const cause = + outcome === "timeout" || outcome === "malformed" ? outcome : "failed"; + return { + cause_code: INSPECTION_CAUSES[cause], + inspection: + observation === "boot-session" || observation === "process-birth" + ? observation + : null, + elapsed_ms: + typeof elapsed === "number" && + Number.isSafeInteger(elapsed) && + elapsed >= 0 && + elapsed <= 300_000 + ? elapsed + : null, + }; +} + +/** + * A reissued diagnostic, or the generic refusal for a proxy. A proxy is refused + * before any reflection, so none of its traps ever runs. Undefined otherwise. + */ +function settledWithoutReflection( + error: object, + phase: NativeAuthoredArtifactPhase +): NativeAuthoredArtifactError | undefined { + const original = issuedDiagnostics.get(error); + if (original) { + return closedFailure(original); + } + if (involvesProxy(error)) { + return closedFailure({ + phase, + cause_code: phase === "action" ? "action_failed" : "artifact_refused", + inspection: null, + elapsed_ms: null, + system_code: null, + error_class: null, + sites: [], + }); + } + return undefined; +} + +function classifyArtifactError( + error: unknown, + phase: NativeAuthoredArtifactPhase +): NativeAuthoredArtifactError { + const isObject = + (typeof error === "object" && error !== null) || + typeof error === "function"; + const settled = isObject ? settledWithoutReflection(error, phase) : undefined; + if (settled) { + return settled; + } + const base = { + phase, + inspection: null, + elapsed_ms: null, + system_code: null, + error_class: errorClass(error), + sites: isObject ? sourceSites(error) : [], + } as const; + if (error instanceof NativeComposeInspectionError) { + return closedFailure({ + ...base, + ...inspectionFields(error), + }); + } + if (error instanceof NativeComposeGenerationError) { + return closedFailure({ + ...base, + cause_code: + ownData(error, "code") === "E_NATIVE_COMPOSE_BUSY" + ? "lock_busy" + : "artifact_refused", + }); + } + if (error instanceof NativeAuthoredHostDeadlineError) { + return closedFailure({ ...base, cause_code: "host_deadline" }); + } + if (error instanceof MuxCommandTimeoutError) { + return closedFailure({ + ...base, + cause_code: "mux_timeout", + }); + } + if (error instanceof LifecycleSessionPresenceUnknownError) { + return closedFailure({ + ...base, + cause_code: "mux_presence_unknown", + }); + } + const code = isObject ? ownData(error, "code") : undefined; + if (typeof code === "string" && SYSTEM_CODES.has(code)) { + return closedFailure({ + ...base, + cause_code: "io_failed", + system_code: code, + }); + } + return closedFailure({ + ...base, + cause_code: phase === "action" ? "action_failed" : "artifact_refused", + }); +} diff --git a/src/backends/native-authored-project-run.ts b/src/backends/native-authored-project-run.ts index cb7d713b5..e8163645b 100644 --- a/src/backends/native-authored-project-run.ts +++ b/src/backends/native-authored-project-run.ts @@ -21,6 +21,10 @@ import { type NativeEnvMetadata, parseNativeEnvMetadata, } from "../lib/native-env-plan-protocol.ts"; +import { + type NativeAuthoredArtifactPhase, + nativeAuthoredArtifactError, +} from "./native-authored-artifact-error.ts"; import { type NativeAuthoredReceipt, type NativeAuthoredReview, @@ -362,6 +366,7 @@ async function storage( branch: input.branch, }; const held: HeldDirectory[] = []; + let phase: NativeAuthoredArtifactPhase = "scope"; try { const projectRoot = await realpath(opts.projectRoot); const nativeHome = await realpath(opts.nativeHome); @@ -373,6 +378,7 @@ async function storage( ) { return refused(); } + phase = "directories"; for (const path of [projectRoot, opts.projectDir, nativeHome]) { held.push(await holdDirectory(path, false)); } @@ -383,6 +389,7 @@ async function storage( branch: opts.branch, directories: held.map(({ info }) => ({ dev: info.dev, ino: info.ino })), }; + phase = "private-store"; for (const [path, privateRoot] of [ [join(opts.projectDir, ".internal"), false], [join(opts.projectDir, ".internal", "native-authored-runs"), true], @@ -390,6 +397,7 @@ async function storage( const directory = await storeDirectory(path, create, privateRoot); if (!directory) { await recheckDirectories(held); + phase = "action"; return await action(undefined, async () => { await recheckDirectories(held); await absent(path); @@ -412,6 +420,7 @@ async function storage( check, }); await check(); + phase = "action"; return await action( { identity, @@ -438,7 +447,7 @@ async function storage( ) { throw error; } - return refused(); + throw nativeAuthoredArtifactError(error, phase); } finally { await Promise.all(held.map(({ file }) => file.close())); } @@ -1032,8 +1041,8 @@ export async function withNativeAuthoredProjectAdmission( } try { await verifyLock(); - } catch { - return refused(); + } catch (error) { + throw nativeAuthoredArtifactError(error, "action"); } }; const withMutation = async ( @@ -1048,8 +1057,8 @@ export async function withNativeAuthoredProjectAdmission( await assertHeld(); }); }); - } catch { - return refused(); + } catch (error) { + throw nativeAuthoredArtifactError(error, "action"); } }; const loadStart = async () => { diff --git a/src/lib/native-compose-private-state.ts b/src/lib/native-compose-private-state.ts index 1a6c99ac6..b76cb7a53 100644 --- a/src/lib/native-compose-private-state.ts +++ b/src/lib/native-compose-private-state.ts @@ -145,9 +145,14 @@ function requireInterruptedSelection( } } -async function inspection( - command: readonly string[] -): Promise<{ readonly output: string; readonly code: number }> { +async function inspection(command: readonly string[]): Promise<{ + readonly output: string; + readonly code: number; + readonly elapsedMs: number; + readonly timedOut: boolean; +}> { + const started = performance.now(); + let timedOut = false; const child = Bun.spawn([...command], { stdin: "ignore", stdout: "pipe", @@ -158,7 +163,10 @@ async function inspection( LC_ALL: "C", }, }); - const timer = setTimeout(() => child.kill("SIGKILL"), 3000); + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, 3000); const observeCpu = beginNativeCpuChild(child, "other"); try { const chunks: Uint8Array[] = []; @@ -185,6 +193,8 @@ async function inspection( .decode(Buffer.concat(chunks)) .trim(), code: await child.exited, + elapsedMs: Math.ceil(performance.now() - started), + timedOut, }; } finally { clearTimeout(timer); @@ -202,8 +212,12 @@ async function bootId(): Promise { "-n", "kern.bootsessionuuid", ]); - if (result.code !== 0) { - refuse(); + if (result.code !== 0 || result.timedOut) { + throw new NativeComposeInspectionError({ + observation: "boot-session", + outcome: result.timedOut ? "timeout" : "failed", + elapsedMs: result.elapsedMs, + }); } value = result.output.toLowerCase(); } else if (process.platform === "linux") { @@ -255,6 +269,13 @@ async function processBirth( "-o", "uid=,lstart=", ]); + if (result.timedOut) { + throw new NativeComposeInspectionError({ + observation: "process-birth", + outcome: "timeout", + elapsedMs: result.elapsedMs, + }); + } if (result.code === 1 && result.output === "") { return null; } @@ -268,7 +289,11 @@ async function processBirth( birth === undefined || !BIRTH.test(birth) ) { - refuse(); + throw new NativeComposeInspectionError({ + observation: "process-birth", + outcome: result.code === 0 ? "malformed" : "failed", + elapsedMs: result.elapsedMs, + }); } return { uid, birth }; } @@ -371,6 +396,22 @@ export class NativeComposeGenerationError extends Error { this.code = code; } } +/** Closed observation details only: neither host-tool output nor process identities escape. */ +export class NativeComposeInspectionError extends NativeComposeGenerationError { + readonly observation: "boot-session" | "process-birth"; + readonly outcome: "timeout" | "failed" | "malformed"; + readonly elapsedMs: number; + constructor(opts: { + readonly observation: "boot-session" | "process-birth"; + readonly outcome: "timeout" | "failed" | "malformed"; + readonly elapsedMs: number; + }) { + super("E_NATIVE_COMPOSE_STATE"); + this.observation = opts.observation; + this.outcome = opts.outcome; + this.elapsedMs = opts.elapsedMs; + } +} function refuse(): never { throw new NativeComposeGenerationError("E_NATIVE_COMPOSE_STATE"); } diff --git a/src/lib/shell.ts b/src/lib/shell.ts index 28fc4186e..b5de2b335 100644 --- a/src/lib/shell.ts +++ b/src/lib/shell.ts @@ -31,6 +31,12 @@ export interface ExecOptions { * held in memory. */ readonly maxStdoutBytes?: number; + /** Passive scalar timing only; observer failures never change command results. */ + readonly onTiming?: (sample: { + readonly elapsedMs: number; + readonly timedOut: boolean; + readonly exitCode: number; + }) => void; } /** Exit code `exec` reports when `maxStdoutBytes` was exceeded. */ @@ -72,6 +78,7 @@ export async function exec( if (timeoutMs !== undefined && timeoutMs <= 0) { return { exitCode: 124, stdout: "", stderr: "" }; } + const started = performance.now(); const proc = Bun.spawn([...cmd], { cwd: opts.cwd, env: buildSpawnEnv(opts.env, opts.unsetEnvKeys), @@ -116,6 +123,18 @@ export async function exec( const exitCode = await proc.exited; observeCpu(exitCode); timeout.dispose(); + try { + const observed: unknown = opts.onTiming?.( + Object.freeze({ + elapsedMs: Math.max(0, performance.now() - started), + timedOut: timeout.didTimeout(), + exitCode, + }) + ); + void Promise.resolve(observed).catch(() => undefined); + } catch { + // Optional diagnostics cannot replace a command's exit or timeout outcome. + } if (overflowed) { return { diff --git a/src/mux/mux-backend.ts b/src/mux/mux-backend.ts index 5296a1e6b..adbfb48db 100644 --- a/src/mux/mux-backend.ts +++ b/src/mux/mux-backend.ts @@ -1,5 +1,57 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { type ExecOptions, type ExecResult, exec } from "../lib/shell.ts"; +const MUX_OPERATIONS = [ + "list-sessions", + "has-session", + "new-session", + "new-window", + "kill-session", + "list-panes", + "list-windows", + "show-options", + "set-option", + "send-keys", + "start-server", +] as const; +type MuxCommandOperation = (typeof MUX_OPERATIONS)[number] | "other"; + +/** Labels are closed even when the command itself is transported over stdin. */ +export function muxCommandOperation( + value: string | undefined +): MuxCommandOperation { + return MUX_OPERATIONS.find((operation) => operation === value) ?? "other"; +} +export type MuxCommandSample = Readonly<{ + backend: "tmux" | "zellij" | "other"; + operation: MuxCommandOperation; + phase: "started" | "finished"; + elapsedMs: number; + timedOut: boolean; + exitCode: number | null; +}>; +const commandObservers = new AsyncLocalStorage< + (sample: MuxCommandSample) => void +>(); + +/** Scope value-free mux timings to one asynchronous caller; no global observer or command arguments. */ +export function observeMuxCommands(opts: { + readonly onSample: (sample: MuxCommandSample) => void; + readonly action: () => T; +}): T { + return commandObservers.run(opts.onSample, opts.action); +} +function recordCommand(sample: MuxCommandSample): void { + try { + const observed: unknown = commandObservers.getStore()?.( + Object.freeze(sample) + ); + void Promise.resolve(observed).catch(() => undefined); + } catch { + // Diagnostic callbacks are passive, including when their caller has failed. + } +} + /** Interactive workspaces use tmux; zellij remains a legacy lifecycle-state tag until migration. */ export type MuxBackendName = "tmux" | "zellij"; @@ -60,12 +112,29 @@ export async function runMuxCommand( command: readonly string[], opts: Omit & { readonly timeoutMs?: number; + readonly diagnosticOperation?: MuxCommandOperation; } = {} ): Promise { + const backend = + command[0] === "tmux" || command[0] === "zellij" ? command[0] : "other"; + const selected = command[1] === "-u" ? command[2] : command[1]; + const operation = muxCommandOperation(opts.diagnosticOperation ?? selected); + recordCommand({ + backend, + operation, + phase: "started", + elapsedMs: 0, + timedOut: false, + exitCode: null, + }); const result = await exec(command, { ...opts, stdin: opts.stdin instanceof Blob ? opts.stdin : "ignore", timeoutMs: opts.timeoutMs ?? muxCommandTimeoutMs(), + onTiming: (sample) => { + recordCommand({ backend, operation, phase: "finished", ...sample }); + return opts.onTiming?.(sample); + }, }); if (result.exitCode === 124) { throw new MuxCommandTimeoutError(command); diff --git a/src/mux/tmux-backend.ts b/src/mux/tmux-backend.ts index e43d67e17..d8a3691ce 100644 --- a/src/mux/tmux-backend.ts +++ b/src/mux/tmux-backend.ts @@ -7,6 +7,7 @@ import { MuxCommandTimeoutError, type MuxSession, type MuxSessionCreateResult, + muxCommandOperation, runMuxCommand, } from "./mux-backend.ts"; import { stageMuxEnvironment } from "./private-environment.ts"; @@ -49,6 +50,7 @@ async function execTmuxSource( ["tmux", "-u", "start-server", ";", "source-file", "-"], { stdin: new Blob([`${args.map(tmuxQuote).join(" ")}\n`]), + diagnosticOperation: muxCommandOperation(args[0]), } ); if (result.exitCode === 0) { diff --git a/tests/helpers/native-fixture-cases.ts b/tests/helpers/native-fixture-cases.ts index fea8a1391..994a6d302 100644 --- a/tests/helpers/native-fixture-cases.ts +++ b/tests/helpers/native-fixture-cases.ts @@ -1,6 +1,14 @@ import { test as bunTest } from "bun:test"; import { AsyncLocalStorage } from "node:async_hooks"; import { rm } from "node:fs/promises"; +import { + type NativeAuthoredArtifactDiagnostic, + observeNativeAuthoredArtifactErrors, +} from "../../src/backends/native-authored-artifact-error.ts"; +import { + type MuxCommandSample, + observeMuxCommands, +} from "../../src/mux/mux-backend.ts"; import { restoreEnv } from "./env.ts"; import { createNativeAuthoredSettlement, @@ -73,6 +81,30 @@ function message(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function muxTimings() { + const samples: MuxCommandSample[] = []; + const artifacts: NativeAuthoredArtifactDiagnostic[] = []; + let dropped = 0; + let droppedArtifacts = 0; + return { + record: (sample: MuxCommandSample) => { + if (samples.length === 256) { + samples.shift(); + dropped++; + } + samples.push(sample); + }, + recordArtifact: (diagnostic: NativeAuthoredArtifactDiagnostic) => { + if (artifacts.length === 256) { + artifacts.shift(); + droppedArtifacts++; + } + artifacts.push(diagnostic); + }, + report: () => ({ samples, dropped, artifacts, droppedArtifacts }), + }; +} + /** * Await `running` for at most `timeoutMs`. The body cannot be cancelled, so a * late rejection is consumed here; it belongs to the case that already failed. @@ -145,6 +177,8 @@ export function createNativeFixtureCases(opts: { body: () => void | Promise, timeoutMs: number ): Promise => { + const originalUmask = process.umask(); + const timings = muxTimings(); const roots: string[] = []; const saved = new Map(); const observed: unknown[] = []; @@ -184,7 +218,14 @@ export function createNativeFixtureCases(opts: { let failure: { readonly error: unknown } | undefined; let bodyRunning = true; const running = (async () => { - await storage.run(context, body); + await observeMuxCommands({ + onSample: timings.record, + action: () => + observeNativeAuthoredArtifactErrors({ + onDiagnostic: timings.recordArtifact, + action: () => storage.run(context, body), + }), + }); })().finally(() => { bodyRunning = false; }); @@ -219,7 +260,11 @@ export function createNativeFixtureCases(opts: { for (const [key, value] of saved) { restoreEnv(key, value); } + process.umask(originalUmask); if (failure !== undefined || unsettled !== undefined) { + console.error( + `${opts.label} mux timings: ${JSON.stringify(timings.report())}` + ); reportRetained({ label: opts.label, roots, failure, unsettled }); } await Promise.all( diff --git a/tests/mux-command-observation.test.ts b/tests/mux-command-observation.test.ts new file mode 100644 index 000000000..307581737 --- /dev/null +++ b/tests/mux-command-observation.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from "bun:test"; +import { + type MuxCommandSample, + MuxCommandTimeoutError, + observeMuxCommands, + runMuxCommand, +} from "../src/mux/mux-backend.ts"; + +const CANARY = "synthetic-private-mux-observation"; + +test("mux timing observations are scoped and omit arguments and output", async () => { + const first: MuxCommandSample[] = []; + const second: MuxCommandSample[] = []; + const results = await Promise.all( + [first, second].map((samples, index) => + observeMuxCommands({ + onSample: (sample) => { + samples.push(sample); + }, + action: () => + runMuxCommand([ + "/bin/sh", + "-c", + `printf '%s' '${CANARY}'; exit ${index + 6}`, + ]), + }) + ) + ); + for (const [index, samples] of [first, second].entries()) { + expect(results[index]).toMatchObject({ + stdout: CANARY, + exitCode: index + 6, + }); + expect(samples.map((sample) => sample.phase)).toEqual([ + "started", + "finished", + ]); + expect(samples[1]).toMatchObject({ + backend: "other", + operation: "other", + timedOut: false, + exitCode: index + 6, + }); + expect(samples[1]?.elapsedMs).toBeGreaterThanOrEqual(0); + expect(JSON.stringify(samples)).not.toContain(CANARY); + expect(Object.isFrozen(samples[1])).toBe(true); + } + await runMuxCommand(["/bin/sh", "-c", "exit 0"]); + expect(first).toHaveLength(2); + expect(second).toHaveLength(2); +}); + +test("mux timing distinguishes a fired timeout from a command that exits 124", async () => { + for (const [script, timedOut] of [ + ["sleep 10", true], + ["exit 124", false], + ] as const) { + const samples: MuxCommandSample[] = []; + await expect( + observeMuxCommands({ + onSample: (sample) => { + samples.push(sample); + }, + action: () => + runMuxCommand(["/bin/sh", "-c", script], { + timeoutMs: timedOut ? 80 : 5000, + }), + }) + ).rejects.toBeInstanceOf(MuxCommandTimeoutError); + expect(samples[1]?.phase).toBe("finished"); + expect(samples[1]?.timedOut).toBe(timedOut); + } +}); + +test("throwing or rejecting timing observers cannot replace a mux result or timeout", async () => { + for (const onSample of [ + () => { + throw new Error(CANARY); + }, + () => Promise.reject(new Error(CANARY)), + ]) { + await expect( + observeMuxCommands({ + onSample, + action: () => runMuxCommand(["/bin/sh", "-c", "exit 7"]), + }) + ).resolves.toMatchObject({ exitCode: 7 }); + await expect( + observeMuxCommands({ + onSample, + action: () => + runMuxCommand(["/bin/sh", "-c", "sleep 10"], { timeoutMs: 80 }), + }) + ).rejects.toBeInstanceOf(MuxCommandTimeoutError); + } + await expect( + runMuxCommand(["/bin/sh", "-c", "exit 7"], { + onTiming: () => Promise.reject(new Error(CANARY)), + }) + ).resolves.toMatchObject({ exitCode: 7 }); +}); diff --git a/tests/native-authored-artifact-error.test.ts b/tests/native-authored-artifact-error.test.ts new file mode 100644 index 000000000..473d6c579 --- /dev/null +++ b/tests/native-authored-artifact-error.test.ts @@ -0,0 +1,272 @@ +import { expect, test } from "bun:test"; +import { dirname } from "node:path"; +import { + NativeAuthoredHostDeadlineError, + nativeAuthoredArtifactError, + observeNativeAuthoredArtifactErrors, +} from "../src/backends/native-authored-artifact-error.ts"; +import { serveLifecycleProcessDelivery } from "../src/lib/lifecycle-process-delivery.ts"; +import { + NativeComposeGenerationError, + NativeComposeInspectionError, +} from "../src/lib/native-compose-private-state.ts"; +import { LifecycleSessionPresenceUnknownError } from "../src/lib/project-lifecycle-sessions.ts"; +import { MuxCommandTimeoutError } from "../src/mux/mux-backend.ts"; + +const CANARY = "synthetic-private-artifact-error"; +test("artifact observation cannot replace a failure when a callback throws or rejects", async () => { + for (const onDiagnostic of [ + () => { + throw new Error(CANARY); + }, + () => Promise.reject(new Error(CANARY)), + ]) { + const failure = observeNativeAuthoredArtifactErrors({ + onDiagnostic, + action: () => + nativeAuthoredArtifactError( + new MuxCommandTimeoutError(["tmux", CANARY]), + "action" + ), + }); + expect(failure.diagnostic.cause_code).toBe("mux_timeout"); + expect(JSON.stringify(failure)).not.toContain(CANARY); + } + await Bun.sleep(0); +}); +test("artifact refusal diagnostics classify closed lock and mux failures without their messages", () => { + for (const [error, cause] of [ + [new NativeComposeGenerationError("E_NATIVE_COMPOSE_BUSY"), "lock_busy"], + [new MuxCommandTimeoutError(["tmux", CANARY]), "mux_timeout"], + [new LifecycleSessionPresenceUnknownError(CANARY), "mux_presence_unknown"], + [new NativeAuthoredHostDeadlineError(), "host_deadline"], + [ + Object.assign(new Error(CANARY), { code: "EAGAIN", path: CANARY }), + "io_failed", + ], + ] as const) { + const failure = nativeAuthoredArtifactError(error, "action"); + expect(failure.diagnostic.cause_code).toBe(cause); + expect(failure.diagnostic.phase).toBe("action"); + expect(String(failure)).not.toContain(CANARY); + expect(JSON.stringify(failure)).not.toContain(CANARY); + expect(failure.cause).toBeUndefined(); + } +}); + +test("inspection diagnostics retain only the fixed observation, outcome and duration", () => { + for (const outcome of ["timeout", "failed", "malformed"] as const) { + const failure = nativeAuthoredArtifactError( + new NativeComposeInspectionError({ + observation: "process-birth", + outcome, + elapsedMs: 3001, + }), + "action" + ); + expect(failure.diagnostic).toMatchObject({ + phase: "action", + cause_code: `lock_inspection_${outcome}`, + inspection: "process-birth", + elapsed_ms: 3001, + system_code: null, + error_class: "Error", + }); + } +}); + +test("arbitrary codes, getters and proxy traps never become public diagnostics", () => { + let invoked = false; + const getter = Object.defineProperty({}, "code", { + get() { + invoked = true; + throw new Error(CANARY); + }, + }); + const proxy = new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error(CANARY); + }, + } + ); + const prototypeTrap = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error(CANARY); + }, + } + ); + for (const error of [ + new Error(CANARY), + { code: CANARY, message: CANARY }, + getter, + proxy, + prototypeTrap, + ]) { + const failure = nativeAuthoredArtifactError(error, "action"); + expect(failure.diagnostic.cause_code).toBe("action_failed"); + expect(failure.diagnostic.system_code).toBeNull(); + expect(JSON.stringify(failure)).not.toContain(CANARY); + } + expect(invoked).toBe(false); +}); + +test("a generic action failure names its source sites and built-in class, never its message or checkout path", async () => { + const refusal = await serveLifecycleProcessDelivery({ + root: CANARY, + launch: { command: [], cwd: CANARY, env: {} }, + signal: new AbortController().signal, + remaining: () => 1000, + assertFresh: () => Promise.resolve(), + }).catch((error: unknown) => error); + const failure = nativeAuthoredArtifactError(refusal, "action"); + expect(failure.diagnostic.cause_code).toBe("action_failed"); + expect(failure.diagnostic.error_class).toBe("Error"); + expect(failure.diagnostic.sites[0]).toMatch( + /^lib\/lifecycle-process-delivery\.ts:\d+$/ + ); + expect(failure.diagnostic.sites.length).toBeGreaterThan(1); + expect(failure.diagnostic.sites.length).toBeLessThanOrEqual(4); + expect(Object.isFrozen(failure.diagnostic.sites)).toBe(true); + const serialized = JSON.stringify(failure); + expect(serialized).not.toContain(CANARY); + expect(serialized).not.toContain(import.meta.dir); + expect(serialized).not.toContain("refused"); +}); + +test("frames outside Hack source, other classes and non-errors keep closed values", () => { + const local = nativeAuthoredArtifactError(new TypeError(CANARY), "action"); + expect(local.diagnostic.error_class).toBe("TypeError"); + expect(local.diagnostic.sites).toEqual([]); + const plain = nativeAuthoredArtifactError({ message: CANARY }, "action"); + expect(plain.diagnostic.error_class).toBe("non_error"); + const forged = nativeAuthoredArtifactError( + Object.assign(new Error(CANARY), { + stack: [ + "Error", + ` at x (${dirname(import.meta.dir)}/src/../${CANARY}/a.ts:1:1)`, + ` at y (/elsewhere/src/lib/${CANARY}.ts:2:1)`, + ` at z (${dirname(import.meta.dir)}/src/lib/a.ts:1234567:1)`, + ].join("\n"), + }), + "action" + ); + expect(forged.diagnostic.sites).toEqual([]); + expect(JSON.stringify(forged)).not.toContain(CANARY); + let invoked = false; + const stackGetter = Object.defineProperty(new Error(CANARY), "stack", { + get() { + invoked = true; + return CANARY; + }, + }); + expect( + nativeAuthoredArtifactError(stackGetter, "action").diagnostic.sites + ).toEqual([]); + expect(invoked).toBe(false); +}); + +test("errno names outside the original set are reported, unknown codes are not", () => { + for (const code of ["EADDRINUSE", "ENAMETOOLONG", "ETIMEDOUT", "ENOTSOCK"]) { + const failure = nativeAuthoredArtifactError( + Object.assign(new Error(CANARY), { code }), + "action" + ); + expect(failure.diagnostic.cause_code).toBe("io_failed"); + expect(failure.diagnostic.system_code).toBe(code); + } + const unknown = nativeAuthoredArtifactError( + Object.assign(new Error(CANARY), { code: "ECANARY" }), + "action" + ); + expect(unknown.diagnostic.cause_code).toBe("action_failed"); + expect(unknown.diagnostic.system_code).toBeNull(); +}); + +test("a proxy error, or one with a proxy prototype, runs none of its traps", () => { + const calls: string[] = []; + const handler: ProxyHandler = new Proxy( + {}, + { + get(_target, trap) { + calls.push(String(trap)); + return () => { + throw new Error(CANARY); + }; + }, + } + ); + const direct = new Proxy(new Error(CANARY), handler); + const inherited = Object.create( + new Proxy(Error.prototype, handler) + ) as object; + for (const error of [direct, inherited]) { + const failure = nativeAuthoredArtifactError(error, "action"); + expect(failure.diagnostic).toMatchObject({ + cause_code: "action_failed", + error_class: null, + system_code: null, + sites: [], + }); + expect(JSON.stringify(failure)).not.toContain(CANARY); + } + expect(calls).toEqual([]); +}); + +test("subclass getters for inspection fields and codes are never read", () => { + let reads = 0; + class Forged extends NativeComposeInspectionError {} + Object.defineProperty(Forged.prototype, "outcome", { + get() { + reads += 1; + return "timeout"; + }, + }); + const forged = Object.create( + Forged.prototype + ) as NativeComposeInspectionError; + Object.defineProperty(forged, "code", { + get() { + reads += 1; + return "E_NATIVE_COMPOSE_BUSY"; + }, + }); + const failure = nativeAuthoredArtifactError(forged, "action"); + expect(failure.diagnostic.cause_code).toBe("lock_inspection_failed"); + expect(failure.diagnostic.inspection).toBeNull(); + expect(reads).toBe(0); +}); + +test("sites come only from frame lines that name a real Hack source file", () => { + const root = `${dirname(import.meta.dir)}/src`; + const real = "backends/native-authored-artifact-error.ts"; + const failure = nativeAuthoredArtifactError( + Object.assign(new Error(CANARY), { + stack: [ + `Error: ${root}/${CANARY}.ts:12:1`, + ` at x (${root}/${CANARY}.ts:12:1)`, + ` at y (${root}/lib/${CANARY}/a.ts:3:1)`, + ` at z (${root}/${real}:7:1)`, + ` at w (${root}/${real}:9:2)`, + ` at ${root}/${real}:11:3`, + ].join("\n"), + }), + "action" + ); + expect(failure.diagnostic.sites).toEqual([`${real}:9`, `${real}:11`]); + expect(JSON.stringify(failure)).not.toContain(CANARY); +}); + +test("an oversized stack is bounded before matching", () => { + const huge = ` at x (${"a".repeat(200_000)}`; + const started = performance.now(); + const failure = nativeAuthoredArtifactError( + Object.assign(new Error(CANARY), { stack: `Error\n${huge}\n${huge}` }), + "action" + ); + expect(performance.now() - started).toBeLessThan(250); + expect(failure.diagnostic.sites).toEqual([]); +}); diff --git a/tests/native-authored-host-processes.test.ts b/tests/native-authored-host-processes.test.ts index 95abff69c..7fb918bd4 100644 --- a/tests/native-authored-host-processes.test.ts +++ b/tests/native-authored-host-processes.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterAll, expect } from "bun:test"; import { mkdir, mkdtemp, @@ -10,12 +10,14 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { NativeAuthoredHostDeadlineError } from "../src/backends/native-authored-artifact-error.ts"; import { nativeHookPermitVersion } from "../src/backends/native-authored-hook-journal.ts"; import { createNativeAuthoredHostProcesses, retireNativeAuthoredHostProcesses, } from "../src/backends/native-authored-host-processes.ts"; import { withNativeAuthoredProjectAdmission } from "../src/backends/native-authored-project-run.ts"; +import { isRecord } from "../src/lib/guards.ts"; import { readLifecycleState, resolveLifecycleStatePath, @@ -26,60 +28,67 @@ import { endLifecycleMuxFixtureGroups, writeLifecycleMuxFixture, } from "./helpers/lifecycle-mux-fixture.ts"; +import { isolateMuxTempDir } from "./helpers/mux-temp.ts"; +import { createNativeFixtureCases } from "./helpers/native-fixture-cases.ts"; -const roots = new Set(); -let unknown = false; -let active = false; -let unconfirmed = false; -let restore: (() => void) | undefined; -afterEach(async () => { - if (active || unknown || unconfirmed) { - unconfirmed = true; - return; - } - restore?.(); - restore = undefined; - for (const root of roots) { - await rm(root, { recursive: true }); - } - roots.clear(); +const cases = createNativeFixtureCases({ + label: "Host process fixture", + defaultTimeoutMs: 20_000, + settlementBudgetMs: 5000, }); +const fixtureParent = tmpdir(); +afterAll(() => cases.drain(), 30_000); +isolateMuxTempDir(); async function fixture() { + const owner = cases.current(); + const abort = new AbortController(); + owner.onFailure(() => abort.abort()); const root = await realpath( - await mkdtemp(join(tmpdir(), "native-host-owner-")) + await mkdtemp(join(fixtureParent, "native-host-owner-")) ); - roots.add(root); + owner.root(root); const projectDir = join(root, ".hack"); const bin = join(root, "bin"); await mkdir(projectDir, { mode: 0o700 }); await mkdir(bin); await writeLifecycleMuxFixture(bin); - const before = { - PATH: process.env.PATH, - HACK_SESSIONS_MUX: process.env.HACK_SESSIONS_MUX, - HACK_TEST_MUX_STATE: process.env.HACK_TEST_MUX_STATE, - }; - const previousUmask = process.umask(0o077); - restore = () => { - process.umask(previousUmask); - for (const [key, value] of Object.entries(before)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }; + const before = owner.saveEnv([ + "PATH", + "HACK_SESSIONS_MUX", + "HACK_TEST_MUX_STATE", + ]); + process.umask(0o077); process.env.PATH = `${bin}:${before.PATH ?? "/usr/bin:/bin"}`; process.env.HACK_SESSIONS_MUX = "tmux"; const state = join(root, "mux.json"); process.env.HACK_TEST_MUX_STATE = state; - const abort = new AbortController(); + owner.onFailure(() => endLifecycleMuxFixtureGroups(state)); + owner.settlement.record({ + kind: "pid-file", + name: "host process fixture groups", + path: state, + group: true, + emptySettled: true, + parse: (text) => { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return; + } + if (!(isRecord(value) && isRecord(value.windows))) { + return; + } + return Object.values(value.windows).flatMap((row: unknown) => + isRecord(row) && typeof row.pid === "number" ? [row.pid] : [] + ); + }, + }); const deadline = performance.now() + 12_000; const remaining = () => { const value = Math.ceil(deadline - performance.now()); if (value < 1 || abort.signal.aborted) { - throw new Error("expired"); + throw new NativeAuthoredHostDeadlineError(); } return value; }; @@ -159,21 +168,7 @@ function ownedCase( name: string, action: (f: Awaited>) => Promise ) { - test(name, async () => { - if (active || unknown || unconfirmed) { - throw new Error("Previous host process case is unresolved"); - } - active = true; - try { - const f = await fixture(); - await action(f); - } catch (error) { - unconfirmed = true; - throw error; - } finally { - active = false; - } - }, 20_000); + cases.test(name, async () => action(await fixture())); } ownedCase( "Source5 private process proof starts the shipping controller and exact-token stop preserves value privacy", @@ -202,7 +197,6 @@ ownedCase( }); expect(nativeHookPermitVersion(permit)).toBe(5); try { - unknown = true; await owner.start(); await owner.assertReady(); const process = ( @@ -222,7 +216,6 @@ ownedCase( } finally { await owner.stop(); await owner.close(); - unknown = false; } await hooks.retire(); }); @@ -231,6 +224,7 @@ ownedCase( ownedCase( "pre-Ready observation refuses live host records without stopping and accepts settled records without retiring", async (f) => { + let unknown = true; await withNativeAuthoredProjectAdmission(f.scope, async (admission) => { const owner = await createNativeAuthoredHostProcesses({ scope: f.scope, @@ -244,7 +238,6 @@ ownedCase( signal: f.abort.signal, remaining: f.remaining, }); - unknown = true; try { await owner.start(); const calls = await readFile(`${f.state}.calls`, "utf8"); @@ -303,7 +296,6 @@ ownedCase( signal: f.abort.signal, remaining: f.remaining, }); - unknown = true; await owner.start(); const state = await readFile(f.state, "utf8"); const foreign = JSON.parse(state); @@ -323,7 +315,6 @@ ownedCase( run, assertFresh: admission.assertHeld, }); - unknown = false; const metadata = JSON.parse( await readFile( resolveLifecycleStatePath({ projectDir: f.projectDir }), @@ -350,7 +341,6 @@ ownedCase( signal: f.abort.signal, remaining: f.remaining, }); - unknown = true; await owner.start(); const proof = await owner.proof(true); const original = `${proof.path}.preserved`; @@ -374,7 +364,6 @@ ownedCase( run, assertFresh: admission.assertHeld, }); - unknown = false; }); } ); diff --git a/tests/native-authored-project-run.test.ts b/tests/native-authored-project-run.test.ts index dde15a1ec..54f082179 100644 --- a/tests/native-authored-project-run.test.ts +++ b/tests/native-authored-project-run.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; import { readdirSync } from "node:fs"; import { @@ -18,6 +18,11 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + type NativeAuthoredArtifactDiagnostic, + NativeAuthoredArtifactError, + observeNativeAuthoredArtifactErrors, +} from "../src/backends/native-authored-artifact-error.ts"; import { parseNativeAuthoredReceipt } from "../src/backends/native-authored-graph-protocol.ts"; import { withNativeAuthoredProjectAdmission as admit, @@ -35,6 +40,7 @@ import { } from "../src/backends/native-project-run.ts"; import { isRecord } from "../src/lib/guards.ts"; import type { NativeEnvMetadata } from "../src/lib/native-env-plan-protocol.ts"; +import { MuxCommandTimeoutError } from "../src/mux/mux-backend.ts"; const roots: string[] = []; afterEach(async () => { @@ -168,6 +174,98 @@ test("storage still redacts arbitrary errors and lookalike routing errors", asyn } }); +test("real admission keeps the value-free mux failure instead of blaming the artifact", async () => { + const scope = await fixture(); + const failure = await admit(scope, async () => { + throw new MuxCommandTimeoutError([ + "tmux", + "synthetic-private-artifact-error", + ]); + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(NativeAuthoredArtifactError); + if (!(failure instanceof NativeAuthoredArtifactError)) { + throw new Error("expected closed admission diagnostic"); + } + expect(failure.diagnostic.cause_code).toBe("mux_timeout"); + expect(failure.diagnostic.phase).toBe("action"); + expect(JSON.stringify(failure)).not.toContain( + "synthetic-private-artifact-error" + ); +}); + +test("scoped admission diagnostics survive an outer startup wrapper replacing the error", async () => { + const scope = await fixture(); + const diagnostics: NativeAuthoredArtifactDiagnostic[] = []; + const failure = await observeNativeAuthoredArtifactErrors({ + onDiagnostic: (diagnostic) => { + diagnostics.push(diagnostic); + }, + action: () => + admit(scope, async () => { + throw new MuxCommandTimeoutError([ + "tmux", + "synthetic-private-artifact-error", + ]); + }).catch(() => { + throw new Error("fixed outer startup error"); + }), + }).catch((error: unknown) => error); + expect(String(failure)).toBe("Error: fixed outer startup error"); + expect( + diagnostics.some((diagnostic) => diagnostic.cause_code === "mux_timeout") + ).toBe(true); + expect(JSON.stringify(diagnostics)).not.toContain( + "synthetic-private-artifact-error" + ); + const count = diagnostics.length; + await admit(scope, async () => { + throw new Error("outside observation"); + }).catch(() => undefined); + expect(diagnostics).toHaveLength(count); +}); + +test("a real lock-inspection deadline survives storage redaction and reaps its child", async () => { + const scope = await fixture(); + const originalSpawn = Bun.spawn; + let child: ReturnType | undefined; + const mocked = spyOn(Bun, "spawn").mockImplementation((...args) => { + const [command] = args; + if (Array.isArray(command) && command[0] === "/bin/ps") { + child = originalSpawn( + [process.execPath, "--no-env-file", "-e", "setTimeout(()=>{},10000)"], + { stdin: "ignore", stdout: "pipe", stderr: "ignore" } + ); + return child; + } + return Reflect.apply(originalSpawn, Bun, args); + }); + try { + let admitted = false; + const failure = await admit(scope, async () => { + admitted = true; + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(NativeAuthoredArtifactError); + if (!(failure instanceof NativeAuthoredArtifactError)) { + throw new Error("expected closed inspection diagnostic"); + } + expect(failure.diagnostic.cause_code).toBe("lock_inspection_timeout"); + expect(failure.diagnostic.inspection).toBe("process-birth"); + expect(failure.diagnostic.elapsed_ms).toBeGreaterThanOrEqual(2900); + expect(admitted).toBe(false); + if (!child) { + throw new Error("expected captured inspection child"); + } + expect(await child.exited).not.toBe(0); + expect(child.signalCode).toBe("SIGKILL"); + } finally { + mocked.mockRestore(); + if (child && child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + await child?.exited; + } +}, 10_000); + test("absent native artifact reads have no writes and storage remains private and excluded", async () => { const opts = await fixture(); expect(await load(opts)).toBeNull(); diff --git a/tests/native-fixture-cases.test.ts b/tests/native-fixture-cases.test.ts index 8be7452f0..1eb621a48 100644 --- a/tests/native-fixture-cases.test.ts +++ b/tests/native-fixture-cases.test.ts @@ -2,6 +2,8 @@ import { afterEach, expect, spyOn, test } from "bun:test"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { NativeAuthoredHostDeadlineError } from "../src/backends/native-authored-artifact-error.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { endLifecycleMuxFixtureGroups, writeLifecycleMuxFixture, @@ -63,12 +65,17 @@ afterEach(() => { test("a failed case runs its failure cleanup, then settles and keeps its own failure", async () => { const noise = spyOn(console, "error").mockImplementation(() => undefined); + const originalUmask = process.umask(); + const previousMux = process.env.HACK_TEST_MUX_STATE; try { let group = 0; await expect( cases.run(() => { group = detachedGroup(); const owner = cases.current(); + owner.saveEnv(["HACK_TEST_MUX_STATE"]); + process.env.HACK_TEST_MUX_STATE = "synthetic-failed-case"; + process.umask(0o077); owner.settlement.record({ kind: "process", name: "held group", @@ -81,12 +88,18 @@ test("a failed case runs its failure cleanup, then settles and keeps its own fai ).rejects.toThrow("deliberate case failure"); // Settlement confirmed absence, so no "also unconfirmed" line was reported. expect(await groupEnded(group)).toBe(true); + await cases.run(() => { + expect(process.env.HACK_TEST_MUX_STATE).toBe(previousMux); + expect(process.umask()).toBe(originalUmask); + }); expect( noise.mock.calls.some((call) => String(call[0]).includes("settlement also unconfirmed") ) ).toBe(false); } finally { + process.umask(originalUmask); + restoreEnv("HACK_TEST_MUX_STATE", previousMux); noise.mockRestore(); } }); @@ -155,6 +168,52 @@ test("a body that outlives its budget runs the failure cleanup again when it end } }); +test("failure aborts a cooperative body before restoring its environment and before its next host step", async () => { + const noise = spyOn(console, "error").mockImplementation(() => undefined); + const release = Promise.withResolvers(); + const abort = new AbortController(); + const previous = process.env.HACK_TEST_MUX_STATE; + let cleanupSawFixture = false; + let hostEffect = false; + let bodyFailure: unknown; + try { + await expect( + cases.run(async () => { + const owner = cases.current(); + owner.saveEnv(["HACK_TEST_MUX_STATE"]); + process.env.HACK_TEST_MUX_STATE = "synthetic-overrun-case"; + owner.onFailure(() => abort.abort()); + owner.onFailure(() => { + cleanupSawFixture = + abort.signal.aborted && + process.env.HACK_TEST_MUX_STATE === "synthetic-overrun-case"; + }); + await release.promise; + try { + if (abort.signal.aborted) { + throw new NativeAuthoredHostDeadlineError(); + } + hostEffect = true; + } catch (error) { + bodyFailure = error; + throw error; + } + }, 20) + ).rejects.toThrow("exceeded its 20 ms budget"); + expect(cleanupSawFixture).toBe(true); + expect(process.env.HACK_TEST_MUX_STATE).toBe(previous); + release.resolve(); + await cases.drain(); + expect(bodyFailure).toBeInstanceOf(NativeAuthoredHostDeadlineError); + expect(hostEffect).toBe(false); + } finally { + release.resolve(); + await cases.drain(); + restoreEnv("HACK_TEST_MUX_STATE", previous); + noise.mockRestore(); + } +}); + test("the stand-in mux cleanup ends recorded window groups and ignores a missing state", async () => { const root = await mkdtemp(join(tmpdir(), "lifecycle-mux-cleanup-")); try { diff --git a/tests/tmux-backend.test.ts b/tests/tmux-backend.test.ts index d3bbfcdd3..791157dd4 100644 --- a/tests/tmux-backend.test.ts +++ b/tests/tmux-backend.test.ts @@ -4,6 +4,10 @@ import { dirname } from "node:path"; import { HACK_KEY_MATERIAL_VARIABLES } from "../src/lib/child-environment.ts"; import type { ExecOptions } from "../src/lib/shell.ts"; +import { + type MuxCommandSample, + observeMuxCommands, +} from "../src/mux/mux-backend.ts"; import { isolateMuxTempDir } from "./helpers/mux-temp.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; @@ -104,12 +108,25 @@ test("tmux session overrides cannot restore Hack key material through -e", async test("tmux creates lifecycle ownership through stdin, never argv", async () => { const backend = await loadTmuxBackend(); - const created = await backend.createSession({ - name: "demo", - cwd: "/repo", - env: { FAKE_API_KEY: "fake-test-key" }, - lifecycleOwnerToken: "owner-123", + const samples: MuxCommandSample[] = []; + const created = await observeMuxCommands({ + onSample: (sample) => { + samples.push(sample); + }, + action: () => + backend.createSession({ + name: "demo", + cwd: "/repo", + env: { FAKE_API_KEY: "fake-test-key" }, + lifecycleOwnerToken: "owner-123", + }), }); + expect(samples.some((sample) => sample.operation === "new-session")).toBe( + true + ); + expect(samples.some((sample) => sample.operation === "start-server")).toBe( + false + ); const ownerToken = await backend.readLifecycleOwnerToken?.({ name: "demo" }); expect(created.ok).toBe(true);