From 0e58d1133317e64e24ce9deb72ba7ff609ec24e0 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Tue, 20 Oct 2026 17:48:48 +0200 Subject: [PATCH 1/4] fix(ci): build runtime packages once per test invocation --- CONTRIBUTING.md | 7 +- docs/decisions/ci-utility.md | 38 +++ docs/process/test-waits.md | 4 +- docs/process/testing.md | 10 +- .../tests/answer-delivery-runtime.test.ts | 4 +- .../tests/approval-recovery-runtime.test.ts | 4 +- .../tests/artifact-recovery-runtime.test.ts | 7 +- .../tests/association-signout-runtime.test.ts | 6 +- .../tests/auth-expiry-runtime.test.ts | 6 +- .../ui-react/tests/chat-focus-runtime.test.ts | 4 +- .../connection-continuity-runtime.test.ts | 5 +- packages/ui-react/tests/draft-runtime.test.ts | 4 +- .../tests/external-speech-runtime.test.ts | 8 +- .../tests/fixtures/runtime-hook-phases.ts | 2 +- .../tests/fixtures/runtime-package-build.ts | 27 +++ .../tests/haiku-model-runtime.test.ts | 4 +- .../tests/html-preview-runtime.test.ts | 5 +- .../ui-react/tests/local-work-runtime.test.ts | 4 +- .../tests/parallel-sessions-runtime.test.ts | 5 +- .../tests/scratch-links-runtime.test.ts | 5 +- .../tests/share-draft-runtime.test.ts | 5 +- .../ui-react/tests/tracker-runtime.test.ts | 5 +- .../tests/turn-journal-runtime.test.ts | 5 +- scripts/build-receipt.ts | 6 +- scripts/runtime-test-builds.ts | 30 +++ scripts/test-invocation.ts | 223 ++++++++++++++++++ scripts/test.ts | 73 ++++-- scripts/unit-workers.ts | 4 +- scripts/workspace-lease.d.mts | 14 ++ scripts/workspace-lease.mjs | 48 +++- tests/helpers/test-invocation-fixture.ts | 51 ++++ tests/helpers/unit-worker-fixture.ts | 16 +- tests/runtime-build-selection.test.ts | 40 ++++ tests/test-invocation-lifetime.test.ts | 177 ++++++++++++++ tests/test-invocation-workers.test.ts | 47 ++++ tests/test-invocation.test.ts | 143 +++++++++++ tests/test-script.test.ts | 2 +- tests/test-shards.test.ts | 2 +- tests/unit-workers.test.ts | 2 +- 39 files changed, 975 insertions(+), 77 deletions(-) create mode 100644 packages/ui-react/tests/fixtures/runtime-package-build.ts create mode 100644 scripts/runtime-test-builds.ts create mode 100644 scripts/test-invocation.ts create mode 100644 tests/helpers/test-invocation-fixture.ts create mode 100644 tests/runtime-build-selection.test.ts create mode 100644 tests/test-invocation-lifetime.test.ts create mode 100644 tests/test-invocation-workers.test.ts create mode 100644 tests/test-invocation.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad310e465..11cc9f2dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,12 @@ issue when you are not sure. repository preload, and the repository browser wrapper wait automatically for other commands using the same checkout's build output. Runtime tests hold exclusive access because their fixtures rebuild the -packages. Every selected package is rebuilt. Persistent build-output reuse is +packages. Coordinated runtime invocations freshly build their selected package +closure once before tests; hooks verify that same live invocation, checkout, +exclusive lease and successful build before omitting duplicate compilation. +Direct or unattested hooks rebuild. Source edits made during a run take effect +on the next invocation. Ordinary explicit builds still rebuild every selected +package. Watch/hot runs remain unattested. Persistent build-output reuse is disabled until its effective configuration, installed toolchain, runtime and stylesheet inputs can be qualified; package versions and symlink target strings are not diff --git a/docs/decisions/ci-utility.md b/docs/decisions/ci-utility.md index f2769d53b..4e84a19cf 100644 --- a/docs/decisions/ci-utility.md +++ b/docs/decisions/ci-utility.md @@ -279,6 +279,44 @@ this now-completed same-source count comparison. Dependency copies and private builds count in proof time and sampled VM resources; setup/install and queues remain separate step measurements. No deadline, shard count or N changed here. +### One fresh build per test invocation + +The [2026-10-20 option-B ruling for #2279](https://github.com/schlessera/brain-kit/issues/2279#issuecomment-6293488248) +permits one fresh package build under the invocation's continuously held write +lease. `scripts/test.ts` prepares the selected runtime consumers' forward +package closure before starting Bun tests. Each private unit worker requests a +full build inside that same build/test lifetime, after namespace entry. There +are still at most two workers, with serial compilers inside each. A worker can +now start tests while its sibling is still preparing; account for this scheduling +placement separately from duplicate compilation avoided. + +The seventeen runtime hooks use a checked helper. Admission requires the live +ancestor coordinator's PID/start identity, invocation nonce, canonical checkout +and root inode, active exclusive lease owner/descriptor/inode, and the actual +builder's completed successful package/child receipt for the required closure. +The coordinator waits for exit and both output EOFs before publishing a bounded +record through its held descriptor. Closing the descriptor or losing the owner +revokes it; leftover files and the builder's best-effort observation fingerprints +have no authority. Failed or signaled preparation stops the invocation before +tests; missing diagnostics after successful preparation leave hooks rebuilding. + +Every ordinary nested wrapper creates a fresh invocation. Only the explicitly +checked isolated external-speech child borrows its live parent's build, outside +both its setup and outer-test deadlines. Direct/unattested hooks rebuild; +standalone coordinated runtime invocations freshly prepare. Shared read batches, +unsupported alternate working directories and watch/hot runs remain unattested. +Ordinary explicit `scripts/build.ts` calls still compile every selected package, +including freshness/configuration/compiler/output-damage fixture controls. +Client/browser bundling stays fresh and consumers keep their default exports. + +Source edits during a run take effect at the next invocation, as explicitly +accepted by the ruling. The lease coordinates cooperating first-party writers; +it does not make source immutable or protect against hostile same-UID processes. +No persistent stamp, boolean skip, generic producer interface, assertion or +deadline change follows. This is distinct from deferred #2319 activation below. +Measure complete preparation, hook admission, test execution and cleanup costs; +small hook durations alone are not a whole-invocation speedup. + ### Opt-in compilation within one hosted workspace The [same-source compilation comparison](https://github.com/schlessera/brain-kit/issues/2321#issuecomment-6233589351) diff --git a/docs/process/test-waits.md b/docs/process/test-waits.md index cf7c45164..309ce4fa2 100644 --- a/docs/process/test-waits.md +++ b/docs/process/test-waits.md @@ -108,10 +108,10 @@ restrict egress. Failed scene preparation closes its owned browser context, including its page listeners, before rethrowing the original failure. The rendering helper (`finishRender`, -`packages/ui-react/tests/connection-continuity-runtime.test.ts:360-385`) waits +`packages/ui-react/tests/connection-continuity-runtime.test.ts:361-386`) waits finite animation completion, fonts and two stable geometry frames. It does **not** claim arbitrary asynchronous work is finished. The negative-window helper -(`settle`, `packages/ui-react/tests/connection-continuity-runtime.test.ts:394-404`) +(`settle`, `packages/ui-react/tests/connection-continuity-runtime.test.ts:395-405`) retains the original 500 ms and two animation frames. It does not add a wait for all finite animations at these negative-observation sites. The finite-animation helper serves the four original rendering call sites, its readiness control, diff --git a/docs/process/testing.md b/docs/process/testing.md index 319c01646..d82f83130 100644 --- a/docs/process/testing.md +++ b/docs/process/testing.md @@ -432,7 +432,15 @@ credentials. A fork run awaiting maintainer approval has not passed. Hosted unit partitions run up to two serial Bun test processes through `scripts/unit-workers.ts`. Each owns its source checkout, copied frozen -install, build output and workspace lease. The wrapper preserves the caller's +install, build output and workspace lease. Full preparation now runs after +namespace entry inside the test wrapper, under one continuous build/test lease; +the two workers keep independent invocation identities and serial compilers. +A prepared worker may start tests while its sibling is still building. Account +for that scheduling change separately from duplicate hook builds. Runtime hooks +verify the live invocation and successful closure receipt before reuse; missing +authority rebuilds, and a failed preparation stops before tests without retry. +See [the invocation rule](../decisions/ci-utility.md#one-fresh-build-per-test-invocation). +The wrapper preserves the caller's umask, drops setup capabilities and binds a private maintenance ledger without changing within-worker child limits or hiding the host's `/tmp` from Docker. Its checkout container lives outside host `/tmp`, which native workers replace diff --git a/packages/ui-react/tests/answer-delivery-runtime.test.ts b/packages/ui-react/tests/answer-delivery-runtime.test.ts index 85b7b63b6..c4c7b3170 100644 --- a/packages/ui-react/tests/answer-delivery-runtime.test.ts +++ b/packages/ui-react/tests/answer-delivery-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; /** * Answer delivery (#910) in real Chrome, on the public ChatPage, against the * real host (`createApp`, its WebSocket, SQLite catalog and dispatch) with a @@ -82,8 +83,7 @@ beforeAll(async () => { // Unit shards start without dist. Generate the real package styles (and // their exported token dependency), as the sibling styled runtime fixtures // do, instead of depending on another test having built them first. - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [buildOut, buildErr, buildCode] = await hook.child("package-build", build); + const [buildOut, buildErr, buildCode] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (buildCode !== 0) throw new Error(`Answer delivery fixture build failed (${buildCode}): ${buildOut}\n${buildErr}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-answer-delivery-")); const brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/approval-recovery-runtime.test.ts b/packages/ui-react/tests/approval-recovery-runtime.test.ts index 92d0a411a..ca47897ac 100644 --- a/packages/ui-react/tests/approval-recovery-runtime.test.ts +++ b/packages/ui-react/tests/approval-recovery-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; @@ -35,8 +36,7 @@ const hook = runtimeHookPhases("approval-recovery-runtime"); beforeAll(async () => { if (!executablePath) return; - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (code !== 0) throw new Error(`Recovery fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-approval-recovery-")); const brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/artifact-recovery-runtime.test.ts b/packages/ui-react/tests/artifact-recovery-runtime.test.ts index 064b9fb93..9e9e8ec5e 100644 --- a/packages/ui-react/tests/artifact-recovery-runtime.test.ts +++ b/packages/ui-react/tests/artifact-recovery-runtime.test.ts @@ -1,3 +1,5 @@ +import { runtimeHookPhases } from "./fixtures/runtime-hook-phases"; +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { afterAll, beforeAll, expect, test } from "bun:test"; import { statSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -47,9 +49,8 @@ async function child(args: string[]) { } beforeAll(async () => { - // A focused source mutation still bundles the client below from its fresh - // source; only already-built, unchanged workspace dependencies may be reused. - if (process.env.BRAIN_ARTIFACT_DEPENDENCIES_BUILT !== "1") await child([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"]); + // The invocation owns fresh package output; client bundling stays fresh below. + await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], runtimeHookPhases("artifact-recovery-runtime.test.ts")); fixture = await mkdtemp(resolve(tmpdir(), "odysseus-artifact-recovery-")); brain = resolve(fixture, "brain"); await createFixtureBrain(repo, brain); diff --git a/packages/ui-react/tests/association-signout-runtime.test.ts b/packages/ui-react/tests/association-signout-runtime.test.ts index ec2aa7ecc..6156666cd 100644 --- a/packages/ui-react/tests/association-signout-runtime.test.ts +++ b/packages/ui-react/tests/association-signout-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, expect, test } from "bun:test"; import { statSync } from "node:fs"; @@ -79,11 +80,8 @@ async function stopHost(h: Host | undefined) { const hook = runtimeHookPhases("association-signout-runtime.test.ts"); beforeAll(() => hook.phase("setup", async () => { if (!executablePath && !process.env.BRAIN_PINNED_CHROMIUM) return; - if (!process.env.BRAIN_AUTH_FIXTURE_BUILT) { - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (code !== 0) throw new Error(`Local work fixture package build failed (${code}): ${out}\n${err}`); - } scratch = await mkdtemp(resolve(tmpdir(), "odysseus-association-signout-")); brain = resolve(scratch, "brain"); await hook.phase("fixture-files", () => createFixtureBrain(repo, brain)); diff --git a/packages/ui-react/tests/auth-expiry-runtime.test.ts b/packages/ui-react/tests/auth-expiry-runtime.test.ts index dcde88258..8b0496731 100644 --- a/packages/ui-react/tests/auth-expiry-runtime.test.ts +++ b/packages/ui-react/tests/auth-expiry-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; @@ -111,11 +112,8 @@ const hook = runtimeHookPhases("auth-expiry-runtime"); beforeAll(async () => { if (!executablePath) return; - if (!process.env.BRAIN_AUTH_FIXTURE_BUILT) { - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (code !== 0) throw new Error(`Local work fixture package build failed (${code}): ${out}\n${err}`); - } scratch = await mkdtemp(resolve(tmpdir(), "odysseus-auth-expiry-")); brain = resolve(scratch, "brain"); await hook.phase("fixture-brain", () => createFixtureBrain(repo, brain)); diff --git a/packages/ui-react/tests/chat-focus-runtime.test.ts b/packages/ui-react/tests/chat-focus-runtime.test.ts index 44ec497ea..4c1ed301b 100644 --- a/packages/ui-react/tests/chat-focus-runtime.test.ts +++ b/packages/ui-react/tests/chat-focus-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { existsSync, statSync } from "node:fs"; @@ -33,8 +34,7 @@ beforeAll(async () => { if (!executablePath) return; // Browser imports use the published default exports. Rebuild from source // on every run so a marker mutation cannot be hidden by stale dist output. - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (code !== 0) throw new Error(`Focus fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-chat-focus-")); brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/connection-continuity-runtime.test.ts b/packages/ui-react/tests/connection-continuity-runtime.test.ts index 76d203337..a1a9a8005 100644 --- a/packages/ui-react/tests/connection-continuity-runtime.test.ts +++ b/packages/ui-react/tests/connection-continuity-runtime.test.ts @@ -1,3 +1,5 @@ +import { runtimeHookPhases } from "./fixtures/runtime-hook-phases"; +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -203,8 +205,7 @@ function listen() { beforeAll(async () => { if (!executablePath) return; - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await Promise.all([new Response(build.stdout).text(), new Response(build.stderr).text(), build.exited]); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], runtimeHookPhases("connection-continuity-runtime.test.ts")); if (code !== 0) throw new Error(`Connection-continuity fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-continuity-")); const brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/draft-runtime.test.ts b/packages/ui-react/tests/draft-runtime.test.ts index b46724394..a99e9a7b8 100644 --- a/packages/ui-react/tests/draft-runtime.test.ts +++ b/packages/ui-react/tests/draft-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; @@ -67,8 +68,7 @@ const HARBOUR = png([30, 90, 160]); const hook = runtimeHookPhases("draft-runtime.test.ts"); beforeAll(() => hook.phase("setup", async () => { if (!executablePath) return; - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (code !== 0) throw new Error(`Draft fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-drafts-")); const brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/external-speech-runtime.test.ts b/packages/ui-react/tests/external-speech-runtime.test.ts index a7a8a830b..9c17ea6f0 100644 --- a/packages/ui-react/tests/external-speech-runtime.test.ts +++ b/packages/ui-react/tests/external-speech-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild, externalSpeechDelegation } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; @@ -33,8 +34,7 @@ if (worker) { if (!executablePath) return; const repo = resolve(import.meta.dir, "../../.."); process.stderr.write("[external-speech] building packages\n"); - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await runtimeHookPhases("external-speech-runtime").child("package-build", build, relay); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], runtimeHookPhases("external-speech-runtime"), relay); if (code !== 0) throw new Error(`Speech fixture package build failed: ${out}\n${err}`); process.stderr.write("[external-speech] constructing local speech and application servers\n"); speechServer = Bun.serve<{ audio: number }>({ hostname: "127.0.0.1", port: 0, @@ -146,9 +146,9 @@ if (worker) describe.skipIf(!executablePath)("external speech through public app else test.skipIf(!executablePath)("external speech: actual browser audio drains and unmount hard-stops capture in an isolated process", async () => { // Parent test files may install DOM/transport globals. The real app/build/ // Chrome harness gets a fresh runtime, as the existing media proof does. - const child = Bun.spawn([process.execPath, "run", "test", import.meta.path], { + const child = Bun.spawn([process.execPath, "run", "test", "--internal-external-speech", import.meta.path], { cwd: resolve(import.meta.dir, "../../.."), - env: { ...process.env, BRAIN_EXTERNAL_SPEECH_WORKER: "1" }, + env: { ...externalSpeechDelegation(resolve(import.meta.dir, "../../..")), BRAIN_EXTERNAL_SPEECH_WORKER: "1" }, stdin: "ignore", stdout: "pipe", stderr: "pipe", }); // Keep stage evidence even if the runner times out before the child exits. diff --git a/packages/ui-react/tests/fixtures/runtime-hook-phases.ts b/packages/ui-react/tests/fixtures/runtime-hook-phases.ts index b4e1290ed..cd72f77a0 100644 --- a/packages/ui-react/tests/fixtures/runtime-hook-phases.ts +++ b/packages/ui-react/tests/fixtures/runtime-hook-phases.ts @@ -57,5 +57,5 @@ export function runtimeHookPhases(file: string) { report(stage, "complete", details); return result; } - return { phase, child }; + return { phase, child, report }; } diff --git a/packages/ui-react/tests/fixtures/runtime-package-build.ts b/packages/ui-react/tests/fixtures/runtime-package-build.ts new file mode 100644 index 000000000..1ae9d86f4 --- /dev/null +++ b/packages/ui-react/tests/fixtures/runtime-package-build.ts @@ -0,0 +1,27 @@ +/** Checked same-invocation preparation, otherwise the ordinary fresh builder. */ +import { invocationBuild, externalSpeechDelegation } from "../../../../scripts/test-invocation"; +import { inheritWorkspaceLease } from "../../../../scripts/workspace-lease.mjs"; +import { runtimeHookPhases } from "./runtime-hook-phases"; +export { externalSpeechDelegation }; + +export async function runtimePackageBuild(root: string, directories: readonly string[], hook: ReturnType, + consume?: (stream: ReadableStream) => Promise): Promise<[string, string, number]> { + const started = performance.now(); + const receipt = invocationBuild(root, directories); + const admissionMs = performance.now() - started; + if (receipt) { + const detail = { disposition: "attested", invocation: receipt.nonce, parentBuildPid: receipt.build.pid, parentBuildId: receipt.build.id, admissionMs }; + hook.report("package-build", "start", detail); + hook.report("package-build", "complete", detail); + return ["", "", 0]; + } + hook.report("package-build", "admission", { disposition: "built", admissionMs }); + const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", directories.join(",")], + inheritWorkspaceLease({ cwd: root, stdin: "ignore", stdout: "pipe", stderr: "pipe" })); + const result = await hook.child("package-build", build, consume); + const resource = build.resourceUsage(); + hook.report("package-build", "cost", { disposition: "built", childPid: build.pid, durationMs: performance.now() - started, + cpuUserUs: resource ? Number(resource.cpuTime.user) : undefined, cpuSystemUs: resource ? Number(resource.cpuTime.system) : undefined, maxRssBytes: resource ? Number(resource.maxRSS) : undefined }); + if (result[2] !== 0 || build.signalCode) throw new Error(`Runtime package build failed (${result[2]}, ${build.signalCode ?? "no signal"}): ${result[0]}\n${result[1]}`); + return result; +} diff --git a/packages/ui-react/tests/haiku-model-runtime.test.ts b/packages/ui-react/tests/haiku-model-runtime.test.ts index 7fb36b51e..30833dcbc 100644 --- a/packages/ui-react/tests/haiku-model-runtime.test.ts +++ b/packages/ui-react/tests/haiku-model-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, expect, test } from "bun:test"; import { statSync, mkdtempSync, rmSync } from "node:fs"; @@ -15,8 +16,7 @@ const hook = runtimeHookPhases("haiku-model-runtime.test.ts"); beforeAll(() => hook.phase("setup", async () => { // Browser consumers follow the published default exports, which resolve to // dist. Build those outputs; never force a Bun condition in the consumer. - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react"], { cwd: join(import.meta.dir, "../../.."), stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(join(import.meta.dir, "../../.."), ["ui-react"], hook); if (code !== 0) throw new Error(`Haiku fixture build failed (${code}): ${out}\n${err}`); const built = await hook.phase("client-build", () => Bun.build({ entrypoints: [join(import.meta.dir, "fixtures/haiku-model-browser.tsx")], target: "browser", outdir: scratch, naming: "client.js" })); if (!built.success) throw new Error(JSON.stringify(built.logs)); diff --git a/packages/ui-react/tests/html-preview-runtime.test.ts b/packages/ui-react/tests/html-preview-runtime.test.ts index 4790eba78..4d5c3804e 100644 --- a/packages/ui-react/tests/html-preview-runtime.test.ts +++ b/packages/ui-react/tests/html-preview-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; // #1084: an HTML file's script runs in the file viewer and in a new tab, but in // an opaque origin that cannot reach the app. Proven in real Chrome, against @@ -42,7 +43,7 @@ let sessionCookie = ""; async function child(args: string[]): Promise { const process = Bun.spawn(args, { cwd: repo, stdout: "pipe", stderr: "pipe", stdin: "ignore" }); - const [out, err, code] = await hook.child(args[1] === "scripts/build.ts" ? "package-build" : "fixture-child", process); + const [out, err, code] = await hook.child("fixture-child", process); if (code !== 0) throw new Error(`HTML preview fixture preparation failed (${code}): ${out}\n${err}`); } @@ -146,7 +147,7 @@ const hook = runtimeHookPhases("html-preview-runtime.test.ts"); beforeAll(() => hook.phase("setup", async () => { if (!executablePath) return; // The client bundle uses fresh source plus ordinary package resolution. - await child([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"]); + await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); fixture = await mkdtemp(resolve(tmpdir(), "odysseus-html-preview-")); attacker = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: (request) => { const url = new URL(request.url); diff --git a/packages/ui-react/tests/local-work-runtime.test.ts b/packages/ui-react/tests/local-work-runtime.test.ts index 6b698025e..9468820a6 100644 --- a/packages/ui-react/tests/local-work-runtime.test.ts +++ b/packages/ui-react/tests/local-work-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; @@ -114,8 +115,7 @@ const hook = runtimeHookPhases("local-work-runtime"); beforeAll(async () => { if (!executablePath) return; - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await hook.child("package-build", build); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); if (code !== 0) throw new Error(`Local work fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-local-work-")); brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/parallel-sessions-runtime.test.ts b/packages/ui-react/tests/parallel-sessions-runtime.test.ts index c34cc9324..ecc8eb3bd 100644 --- a/packages/ui-react/tests/parallel-sessions-runtime.test.ts +++ b/packages/ui-react/tests/parallel-sessions-runtime.test.ts @@ -1,3 +1,5 @@ +import { runtimeHookPhases } from "./fixtures/runtime-hook-phases"; +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -220,8 +222,7 @@ async function restartHost() { beforeAll(async () => { if (!executablePath) return; - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await Promise.all([new Response(build.stdout).text(), new Response(build.stderr).text(), build.exited]); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], runtimeHookPhases("parallel-sessions-runtime.test.ts")); if (code !== 0) throw new Error(`Parallel-sessions fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-parallel-")); const brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/scratch-links-runtime.test.ts b/packages/ui-react/tests/scratch-links-runtime.test.ts index 2dcfe301c..78ff6bb9a 100644 --- a/packages/ui-react/tests/scratch-links-runtime.test.ts +++ b/packages/ui-react/tests/scratch-links-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; // #544: the actual scanner -> clicked FileLink -> store -> served app -> // PDF/image viewer chain. Regex/classifier controls alone missed this failure. import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; @@ -30,7 +31,7 @@ const bytes: Record = {}; async function child(args: string[], cwd = repo): Promise { const process = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe", stdin: "ignore" }); - const [out, err, code] = await hook.child(args[1] === "scripts/build.ts" ? "package-build" : "client-build", process); + const [out, err, code] = await hook.child("client-build", process); if (code !== 0) throw new Error(`Scratch fixture preparation failed (${code}): ${out}\n${err}`); } @@ -40,7 +41,7 @@ beforeAll(async () => { if (!executablePath) return; // The client bundle uses fresh source plus ordinary package resolution. // Rebuild dependencies so a runtime mutation cannot hide behind stale dist. - await child([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"]); + await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); fixture = await mkdtemp(resolve(tmpdir(), "odysseus-scratch-links-")); brain = resolve(fixture, "brain"); await hook.phase("fixture-brain", () => createFixtureBrain(repo, brain)); diff --git a/packages/ui-react/tests/share-draft-runtime.test.ts b/packages/ui-react/tests/share-draft-runtime.test.ts index 80a001bb6..e494cc8bd 100644 --- a/packages/ui-react/tests/share-draft-runtime.test.ts +++ b/packages/ui-react/tests/share-draft-runtime.test.ts @@ -1,3 +1,5 @@ +import { runtimeHookPhases } from "./fixtures/runtime-hook-phases"; +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { afterAll, beforeAll, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { statSync } from "node:fs"; @@ -26,8 +28,7 @@ const prompts: Array<{ sessionId: string; text: string; images: number }> = []; let sequence = 0; beforeAll(async () => { // Browser bundles resolve the SDK's default dist exports, not its Bun source condition. - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-sdk"], { cwd: resolve(import.meta.dir, "../../.."), stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await Promise.all([new Response(build.stdout).text(), new Response(build.stderr).text(), build.exited]); + const [out, err, code] = await runtimePackageBuild(resolve(import.meta.dir, "../../.."), ["ui-sdk"], runtimeHookPhases("share-draft-runtime.test.ts")); if (code !== 0) throw new Error(`Share fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(join(tmpdir(), "odysseus-share-draft-")); const brain = join(scratch, "brain"); diff --git a/packages/ui-react/tests/tracker-runtime.test.ts b/packages/ui-react/tests/tracker-runtime.test.ts index a1357bd0b..5c0494502 100644 --- a/packages/ui-react/tests/tracker-runtime.test.ts +++ b/packages/ui-react/tests/tracker-runtime.test.ts @@ -1,3 +1,5 @@ +import { runtimeHookPhases } from "./fixtures/runtime-hook-phases"; +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { statSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -38,8 +40,7 @@ const LOG = Array.from({ length: 60 }, (_, i) => `Day ${i + 1}: the wax held, an beforeAll(async () => { if (!executablePath) return; - const build = Bun.spawn([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"], { cwd: repo, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await Promise.all([new Response(build.stdout).text(), new Response(build.stderr).text(), build.exited]); + const [out, err, code] = await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], runtimeHookPhases("tracker-runtime.test.ts")); if (code !== 0) throw new Error(`Tracker fixture package build failed (${code}): ${out}\n${err}`); scratch = await mkdtemp(resolve(tmpdir(), "odysseus-trackers-")); const brain = resolve(scratch, "brain"); diff --git a/packages/ui-react/tests/turn-journal-runtime.test.ts b/packages/ui-react/tests/turn-journal-runtime.test.ts index 477024265..d2bb642cb 100644 --- a/packages/ui-react/tests/turn-journal-runtime.test.ts +++ b/packages/ui-react/tests/turn-journal-runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimePackageBuild } from "./fixtures/runtime-package-build"; import { runtimeHookPhases } from "./fixtures/runtime-hook-phases.ts"; import { createFixtureWorkerStartupView } from "../../ui-sdk/tests/helpers/worker-startup-view-fixture.js"; import { afterAll, beforeAll, expect, test } from "bun:test"; @@ -26,12 +27,12 @@ const observability = createRecordingObservability(); let root = "", origin = "", browser: Browser, app: Awaited>, server: ReturnType; async function child(args: string[]) { const process = Bun.spawn(args, { cwd: repo, stdout: "pipe", stderr: "pipe", stdin: "ignore" }); - const [out, err, code] = await hook.child(args[1] === "scripts/build.ts" ? "package-build" : "fixture-child", process); + const [out, err, code] = await hook.child("fixture-child", process); if (code !== 0) throw new Error(`Journal fixture build failed (${code})\n${out}${err}`); } const hook = runtimeHookPhases("turn-journal-runtime.test.ts"); beforeAll(() => hook.phase("setup", async () => { - if (process.env.BRAIN_JOURNAL_DEPENDENCIES_BUILT !== "1") await child([process.execPath, "scripts/build.ts", "--for", "ui-react,ui-server,core"]); + await runtimePackageBuild(repo, ["ui-react", "ui-server", "core"], hook); root = await mkdtemp(resolve(tmpdir(), "odysseus-journal-browser-")); const brain = resolve(root, "brain"), assets = resolve(root, "assets"); await hook.phase("fixture-files", () => createFixtureBrain(repo, brain)); await mkdir(assets); diff --git a/scripts/build-receipt.ts b/scripts/build-receipt.ts index 32f367379..34ee9c650 100644 --- a/scripts/build-receipt.ts +++ b/scripts/build-receipt.ts @@ -17,10 +17,12 @@ export function buildReceipt(root: string, options: { jobs: number; subset: bool try { console.error(JSON.stringify({ kind: "workspace-build-receipt-unavailable", pid: process.pid })); } catch {} } const directory = join(root, "tmp"); - const path = join(directory, `build-${process.pid}-${randomUUID()}-probe.json`); + const requested = process.env.BRAIN_TEST_BUILD_ID; + const id = requested && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(requested) ? requested : randomUUID(); + const path = join(directory, `build-${process.pid}-${id}-probe.json`); const started = performance.now(); const packages: Package[] = options.packages.map(name => ({ name, state: "pending", children: [] })); - const receipt = { schemaVersion: 1, kind: "workspace-build", pid: process.pid, startedAt: Date.now(), + const receipt = { schemaVersion: 1, kind: "workspace-build", id, pid: process.pid, startedAt: Date.now(), mode: options.subset ? "subset" : "full", jobs: options.jobs, state: "running" as "running" | "complete", elapsedMs: 0, success: false, code: null as number | null, packages }; const elapsed = () => performance.now() - started; diff --git a/scripts/runtime-test-builds.ts b/scripts/runtime-test-builds.ts new file mode 100644 index 000000000..311d831fa --- /dev/null +++ b/scripts/runtime-test-builds.ts @@ -0,0 +1,30 @@ +/** Fixed runtime consumers; this table never narrows Bun's test discovery. */ +import { existsSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; + +export const runtimeTestBuilds: Readonly> = Object.fromEntries([ + "answer-delivery", "approval-recovery", "artifact-recovery", "association-signout", "auth-expiry", "chat-focus", + "connection-continuity", "draft", "external-speech", "html-preview", "local-work", "parallel-sessions", "scratch-links", "tracker", "turn-journal", +].map(name => [`packages/ui-react/tests/${name}-runtime.test.ts`, ["ui-react", "ui-server", "core"]])); +const special: Readonly> = { + "packages/ui-react/tests/haiku-model-runtime.test.ts": ["ui-react"], + "packages/ui-react/tests/share-draft-runtime.test.ts": ["ui-sdk"], +}; +export const runtimeBuildInventory = { ...runtimeTestBuilds, ...special }; + +/** Paths are already parsed by the wrapper's value-aware Bun argument parser. */ +export function runtimeBuildSelection(root: string, cwd: string, paths: readonly string[]): string[] { + const selected = new Set(); + for (const [file, packages] of Object.entries(runtimeBuildInventory)) { + if (!existsSync(join(root, file))) continue; + const absolute = join(root, file); + if (paths.some(path => { + if (isAbsolute(path) || path.startsWith(".")) { + const target = resolve(cwd, path); + return absolute === target || absolute.startsWith(`${target}/`); + } + return absolute.includes(path); + })) for (const pkg of packages) selected.add(pkg); + } + return [...selected]; +} diff --git a/scripts/test-invocation.ts b/scripts/test-invocation.ts new file mode 100644 index 000000000..e9e84dd3d --- /dev/null +++ b/scripts/test-invocation.ts @@ -0,0 +1,223 @@ +/** One fresh build owned by one live test invocation; never cross-run freshness. */ +import { closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { createHash, randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { selectBuildPackages } from "./publishable-packages"; +import { inheritWorkspaceLease, liveAncestor, processIdentity, workspaceLeaseIdentity } from "./workspace-lease.mjs"; + +const AUTHORITY = "BRAIN_TEST_BUILD_AUTHORITY"; +const NONCE = "BRAIN_TEST_INVOCATION_NONCE"; +const DELEGATION = "BRAIN_TEST_SPEECH_DELEGATION"; +const uuid = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/; +const digest = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex"); +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); +const positive = (n: unknown): n is number => Number.isSafeInteger(n) && Number(n) > 0; +const identity = (root: string) => { const path = realpathSync(root), st = statSync(path); return { path, dev: st.dev, ino: st.ino }; }; +const event = (event: string, data: Record = {}) => console.error(JSON.stringify({ kind: "test-invocation", event, pid: process.pid, at: Date.now(), ...data })); + +type Authority = { version: 1; nonce: string; ownerPid: number; ownerStart: string; fd: number; dev: number; ino: number; sha256: string }; +type CompletedBuild = { schemaVersion: number; kind: string; pid: number; id: string; state: string; success: boolean; code: number; jobs: number; + packages: { name: string; state: string; children: { compiler: string; pid: number; code: number; elapsedMs: number }[] }[] }; +type InvocationRecord = { version: 1; nonce: string; ownerPid: number; ownerStart: string; root: ReturnType; lease: NonNullable>; build: CompletedBuild }; + +function readRegular(path: string): { bytes: Buffer; dev: number; ino: number } { + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const before = fstatSync(fd); + if (!before.isFile() || before.size < 2 || before.size > 1_048_576) throw new Error("Invalid invocation receipt file"); + const bytes = readFileSync(fd), after = fstatSync(fd); + if (bytes.length !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error("Changed invocation receipt"); + return { bytes, dev: before.dev, ino: before.ino }; + } finally { closeSync(fd); } +} + +function completedBuild(root: string, build: CompletedBuild, pid: number, id: string, expected?: readonly string[]): boolean { + if (!build || build.schemaVersion !== 1 || build.kind !== "workspace-build" || build.pid !== pid || build.id !== id || + !positive(pid) || !uuid.test(id) || build.state !== "complete" || build.success !== true || build.code !== 0 || build.jobs !== 1 || + !Array.isArray(build.packages) || !build.packages.length || build.packages.length > 128) return false; + const names = build.packages.map(pkg => pkg?.name); + if (names.some(name => typeof name !== "string") || new Set(names).size !== names.length || + (expected && !same(names, expected)) || !same(names, selectBuildPackages(root, names).map(pkg => pkg.dir))) return false; + return build.packages.every(pkg => pkg.state === "complete" && Array.isArray(pkg.children) && + same(pkg.children.map(child => child.compiler), ["tsc", ...(["ui-react", "ui-kit"].includes(pkg.name) ? ["css"] : [])]) && + pkg.children.every(child => positive(child.pid) && child.code === 0 && Number.isFinite(child.elapsedMs) && child.elapsedMs >= 0)); +} + +/** Check the live owner's descriptor, never a pathname from the environment. */ +export function invocationBuild(root: string, directories: readonly string[]): InvocationRecord | undefined { + try { + if (process.platform !== "linux" || (process.env[AUTHORITY]?.length ?? 0) > 4096) return; + // Direct Bun watch/hot entrypoints can inherit a live outer locator too. + if (readFileSync("/proc/self/cmdline", "utf8").split("\0").some(arg => /^(?:--watch|--hot)(?:=|$)/.test(arg))) return; + const locator = JSON.parse(process.env[AUTHORITY] ?? "null") as Authority | null; + if (!locator || locator.version !== 1 || !uuid.test(locator.nonce) || locator.nonce !== process.env[NONCE] || + !positive(locator.ownerPid) || !positive(locator.fd) || locator.fd < 3 || !/^[0-9a-f]{64}$/.test(locator.sha256) || + !liveAncestor(locator.ownerPid, locator.ownerStart)) return; + const path = `/proc/${locator.ownerPid}/fd/${locator.fd}`; + const st = statSync(path); + if (!st.isFile() || st.dev !== locator.dev || st.ino !== locator.ino || st.size < 2 || st.size > 1_048_576) return; + // /proc's descriptor is deliberately followed; no caller-controlled path is opened. + const bytes = readFileSync(path); + if (bytes.length !== st.size || digest(bytes) !== locator.sha256) return; + const record = JSON.parse(bytes.toString()) as InvocationRecord; + const lease = workspaceLeaseIdentity(root); + if (record.version !== 1 || record.nonce !== locator.nonce || record.ownerPid !== locator.ownerPid || record.ownerStart !== locator.ownerStart || + !same(record.root, identity(root)) || !lease || lease.mode !== "write" || !same(record.lease, lease) || + !completedBuild(root, record.build, record.build?.pid, record.build?.id)) return; + const built = new Set(record.build.packages.map(pkg => pkg.name)); + if (!selectBuildPackages(root, directories).every(pkg => built.has(pkg.dir))) return; + const after = statSync(path); + if (after.dev !== st.dev || after.ino !== st.ino || after.size !== st.size || after.ctimeMs !== st.ctimeMs || + !liveAncestor(locator.ownerPid, locator.ownerStart)) return; + return record; + } catch { /* Missing, stale or malformed authority always rebuilds in the hook. */ } +} + +/** Only the isolated external-speech wrapper may deliberately borrow this run. */ +export function externalSpeechDelegation(root: string): NodeJS.ProcessEnv { + const record = invocationBuild(root, ["ui-react", "ui-server", "core"]); + return { ...process.env, [DELEGATION]: record ? JSON.stringify({ nonce: record.nonce, pid: process.pid, start: processIdentity(process.pid)?.start }) : "" }; +} + +export function acceptsSpeechDelegation(root: string, paths: readonly string[]): boolean { + try { + if (process.env.BRAIN_EXTERNAL_SPEECH_WORKER !== "1" || paths.length !== 1 || + resolve(root, paths[0]!) !== join(root, "packages/ui-react/tests/external-speech-runtime.test.ts")) return false; + const request = JSON.parse(process.env[DELEGATION] ?? "null"); + const record = invocationBuild(root, ["ui-react", "ui-server", "core"]); + return Boolean(record && request?.nonce === record.nonce && liveAncestor(request.pid, request.start)); + } catch { return false; } +} + +export function clearInvocation(): string { + delete process.env[AUTHORITY]; delete process.env[DELEGATION]; + return process.env[NONCE] = randomUUID(); +} + +/** Called after write admission. Caller forwards cancellation throughout build/test. */ +export async function prepareInvocation(root: string, directories: readonly string[] | undefined, + started: (child: ReturnType) => void): Promise<{ code: number; dispose: () => void }> { + const nonce = process.env[NONCE]!; + const selected = selectBuildPackages(root, directories).map(pkg => pkg.dir); + const buildId = randomUUID(), before = performance.now(); + const child = Bun.spawn([process.execPath, join(root, "scripts/build.ts"), ...(directories ? ["--for", directories.join(",")] : [])], + inheritWorkspaceLease({ cwd: root, stdin: "ignore", stdout: "pipe", stderr: "pipe", env: { ...process.env, BRAIN_TEST_BUILD_ID: buildId } })); + started(child); + event("build-start", { nonce, builderPid: child.pid, buildId, packages: selected }); + async function drain(stream: ReadableStream, channel: "stdout" | "stderr") { + let bytes = 0; + for await (const chunk of stream) { bytes += chunk.length; process[channel].write(chunk); } + event(`${channel}-complete`, { nonce, builderPid: child.pid, bytes }); + } + const results = await Promise.allSettled([child.exited.then(code => { event("build-exit", { nonce, builderPid: child.pid, code, signal: child.signalCode }); return code; }), + drain(child.stdout as ReadableStream, "stdout"), drain(child.stderr as ReadableStream, "stderr")]); + const resource = child.resourceUsage(); + const usage = resource ? { cpuUserUs: Number(resource.cpuTime.user), cpuSystemUs: Number(resource.cpuTime.system), + maxRssBytes: Number(resource.maxRSS) } : undefined; + const exited = results[0]; + const code = exited.status === "fulfilled" ? exited.value : 1; + if (code !== 0 || child.signalCode || results.some(result => result.status === "rejected")) { + event("preparation-failed", { nonce, builderPid: child.pid, code, usage, elapsedMs: performance.now() - before }); + return { code: code || 1, dispose() {} }; + } + let fd: number | undefined; + let directory: { path: string; dev: number; ino: number } | undefined; + let named: { path: string; dev: number; ino: number } | undefined; + const dispose = () => { + delete process.env[AUTHORITY]; + const errors: unknown[] = []; + if (fd !== undefined) { try { closeSync(fd); } catch (error) { errors.push(error); } fd = undefined; } + if (named) { + try { + const current = lstatSync(named.path, { throwIfNoEntry: false }); + if (current) { + if (!current.isFile() || current.dev !== named.dev || current.ino !== named.ino) throw new Error("Replaced invocation receipt; retained scratch"); + unlinkSync(named.path); + } + named = undefined; + } catch (error) { errors.push(error); } + } + if (directory) { + try { + const current = lstatSync(directory.path, { throwIfNoEntry: false }); + if (current) { + if (!current.isDirectory() || current.dev !== directory.dev || current.ino !== directory.ino) throw new Error("Replaced invocation directory; retained scratch"); + // Never recurse: an unrelated entry or failed owned unlink is retained. + rmdirSync(directory.path); + } + directory = undefined; + } catch (error) { errors.push(error); } + } + if (errors.length) throw new AggregateError(errors, "Invocation receipt cleanup failed"); + }; + try { + const receipt = readRegular(join(root, "tmp", `build-${child.pid}-${buildId}-probe.json`)); + const build = JSON.parse(receipt.bytes.toString()) as CompletedBuild; + const lease = workspaceLeaseIdentity(root), ownerStart = processIdentity(process.pid)?.start; + if (!completedBuild(root, build, child.pid, buildId, selected) || !lease || lease.mode !== "write" || !ownerStart || !uuid.test(nonce)) throw new Error("Unattested completed preparation"); + const record: InvocationRecord = { version: 1, nonce, ownerPid: process.pid, ownerStart, root: identity(root), lease, build }; + const bytes = JSON.stringify(record); + const scratch = mkdtempSync(join(tmpdir(), "brain-test-invocation-")), dirStat = lstatSync(scratch); + directory = { path: scratch, dev: dirStat.dev, ino: dirStat.ino }; + const path = join(scratch, "receipt"); + const writer = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + const created = fstatSync(writer); + named = { path, dev: created.dev, ino: created.ino }; + try { writeFileSync(writer, bytes); } finally { closeSync(writer); } + fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const st = fstatSync(fd); + if (!st.isFile() || st.dev !== named.dev || st.ino !== named.ino) throw new Error("Replaced invocation receipt before publication"); + unlinkSync(path); named = undefined; + rmdirSync(scratch); directory = undefined; + const locator: Authority = { version: 1, nonce, ownerPid: process.pid, ownerStart, fd, dev: st.dev, ino: st.ino, sha256: digest(bytes) }; + process.env[AUTHORITY] = JSON.stringify(locator); + event("build-attested", { nonce, builderPid: child.pid, buildId, packages: selected, usage, elapsedMs: performance.now() - before }); + return { code: 0, dispose }; + } catch { + dispose(); + event("build-unattested", { nonce, builderPid: child.pid, usage, elapsedMs: performance.now() - before }); + return { code: 0, dispose() {} }; + } +} + +/** Stop this invocation's actual child tree before draining/releasing ownership. + * Nested wrappers share the enclosing flock/group, so killing only their builder + * cannot rely on the enclosing group exiting. PID/start pairs bound every signal. + */ +export function invocationChild(child: ReturnType) { + const start = processIdentity(child.pid)?.start; + let stopped: Promise | undefined; + return { + stop(signal: NodeJS.Signals): Promise { + if (stopped) return stopped; + stopped = (async () => { + if (process.platform !== "linux" || !start) { child.kill(signal); await child.exited; return; } + if (processIdentity(child.pid)?.start !== start) return; + const processes = readdirSync("/proc").filter(name => /^\d+$/.test(name)).flatMap(name => { + const pid = Number(name), state = processIdentity(pid); + return state ? [{ pid, ...state }] : []; + }); + const owned = [{ pid: child.pid, start }]; + for (let index = 0; index < owned.length; index++) { + for (const process of processes) if (process.parent === owned[index]!.pid && !owned.some(item => item.pid === process.pid)) owned.push(process); + } + const live = (item: { pid: number; start: string }) => processIdentity(item.pid)?.start === item.start; + const signalOwned = (signal: NodeJS.Signals) => { + for (const item of [...owned].reverse()) if (live(item)) { + try { process.kill(item.pid, signal); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } + } + }; + signalOwned(signal); + const grace = Date.now() + 1_000; + while (owned.some(live) && Date.now() < grace) await Bun.sleep(10); + if (owned.some(live)) signalOwned("SIGKILL"); + await child.exited; + const end = Date.now() + 1_000; + while (owned.some(live) && Date.now() < end) await Bun.sleep(10); + if (owned.some(live)) throw new Error("Invocation child cleanup incomplete"); + })(); + return stopped; + }, + }; +} diff --git a/scripts/test.ts b/scripts/test.ts index f11d243de..b7dea3c3a 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -18,6 +18,9 @@ import { constants } from "node:os"; import { join, resolve } from "node:path"; +import { realpathSync } from "node:fs"; +import { runtimeBuildSelection } from "./runtime-test-builds"; +import { acceptsSpeechDelegation, clearInvocation, invocationChild, prepareInvocation } from "./test-invocation"; import { ensureWorkspaceLease, inheritWorkspaceLease, workspaceTestAccess } from "./workspace-lease.mjs"; /** What runs when the caller names no path. */ @@ -95,12 +98,12 @@ function shortTakesNext(token: string): boolean { } /** The `bun test` argv for the arguments `bun run test` received. */ -export function testArgv(args: readonly string[]): string[] { - let hasPath = false; +export function testPaths(args: readonly string[]): string[] { + const paths: string[] = []; for (let i = 0; i < args.length; i++) { const arg = args[i]!; if (arg === "--") { - hasPath ||= i + 1 < args.length; + paths.push(...args.slice(i + 1)); break; } // A bare `-` is a positional to Bun, like any other path. @@ -112,9 +115,13 @@ export function testArgv(args: readonly string[]): string[] { } continue; } - hasPath = true; + paths.push(arg); } - return hasPath ? [...args] : [...DEFAULT_PATH_ARGS, ...args]; + return paths; +} + +export function testArgv(args: readonly string[]): string[] { + return testPaths(args).length ? [...args] : [...DEFAULT_PATH_ARGS, ...args]; } if (import.meta.main) { @@ -122,6 +129,7 @@ if (import.meta.main) { if (coordinated !== undefined) process.exit(coordinated); let args = process.argv.slice(2); let argv: string[]; + let fullPreparation = false, delegatedSpeech = false, unsupported = realpathSync(process.cwd()) !== realpathSync(resolve(import.meta.dir, "..")); try { const balanced: string[] = []; const forwarded: string[] = []; @@ -131,6 +139,9 @@ if (import.meta.main) { forwarded.push(...args.slice(i)); break; } + if (arg === "--internal-full-preparation") { fullPreparation = true; continue; } + if (arg === "--internal-external-speech") { delegatedSpeech = true; continue; } + if (["--watch", "--hot", "--cwd"].some(flag => arg === flag || arg.startsWith(`${flag}=`))) unsupported = true; if (arg === "--balanced-shard") throw new Error("Expected --balanced-shard=M/N"); if (arg.startsWith("--balanced-shard=")) { balanced.push(arg); @@ -157,15 +168,47 @@ if (import.meta.main) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } - const proc = Bun.spawn([process.execPath, "test", "--preload", join(import.meta.dir, "test-network-preload.ts"), ...argv], inheritWorkspaceLease({ - stdio: ["inherit", "inherit", "inherit"], - })); - // A cancellation aimed at this process's PID (a CI runner stopping the - // step, `kill `) must stop the tests too, not orphan them. Ctrl-C in a - // terminal already reaches both through the foreground process group. - for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { - process.on(signal, () => proc.kill(signal)); + const root = resolve(import.meta.dir, ".."); + // Cancellation covers preparation as well as tests; an interrupted build is + // never retried and cannot launch tests, even if it was just exiting zero. + let cancelled: NodeJS.Signals | undefined; + const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; + let owned: ReturnType | undefined; + const stopping: Promise[] = []; + const stop = (signal: NodeJS.Signals) => { const pending = owned?.stop(signal); if (pending) { stopping.push(pending); void pending.catch(() => {}); } }; + const started = (child: ReturnType) => { owned = invocationChild(child); if (cancelled) stop(cancelled); }; + const listeners = signals.map(signal => () => { cancelled = signal; stop(signal); }); + signals.forEach((signal, index) => process.on(signal, listeners[index]!)); + let dispose = () => {}; + let result = 1; + try { + const paths = testPaths(argv); + const borrowed = !unsupported && workspaceTestAccess() === "write" && delegatedSpeech && acceptsSpeechDelegation(root, paths); + if (!borrowed) clearInvocation(); + if (fullPreparation && workspaceTestAccess() !== "write") throw new Error("Read-only verification cannot prepare workspace output"); + const directories = runtimeBuildSelection(root, process.cwd(), paths); + if (!borrowed && !unsupported && workspaceTestAccess() === "write" && (fullPreparation || directories.length)) { + const preparation = await prepareInvocation(root, fullPreparation ? undefined : directories, started); + dispose = preparation.dispose; + if (preparation.code !== 0) { result = preparation.code; throw new Error(`Test invocation preparation failed (${result})`); } + } + if (!cancelled) { + const tests = Bun.spawn([process.execPath, "test", "--preload", join(import.meta.dir, "test-network-preload.ts"), ...argv], inheritWorkspaceLease({ + stdin: "inherit", stdout: "pipe", stderr: "pipe", + })); + started(tests); + const relay = async (stream: ReadableStream, channel: "stdout" | "stderr") => { for await (const chunk of stream) process[channel].write(chunk); }; + const joined = await Promise.allSettled([tests.exited, relay(tests.stdout, "stdout"), relay(tests.stderr, "stderr")]); + result = joined[0].status === "fulfilled" ? joined[0].value : 1; + if (joined.some(item => item.status === "rejected")) result = result || 1; + if (tests.signalCode) result = 128 + (constants.signals[tests.signalCode] ?? 0); + } + } catch (error) { console.error(error); } + finally { + const stopped = await Promise.allSettled(stopping); + if (stopped.some(item => item.status === "rejected")) { console.error("Invocation child cleanup failed", stopped); result = result || 1; } + dispose(); + signals.forEach((signal, index) => process.off(signal, listeners[index]!)); } - const code = await proc.exited; - process.exit(proc.signalCode ? 128 + (constants.signals[proc.signalCode] ?? 0) : code); + process.exit(cancelled ? 128 + (constants.signals[cancelled] ?? 0) : result); } diff --git a/scripts/unit-workers.ts b/scripts/unit-workers.ts index d379d0363..1737bfe63 100644 --- a/scripts/unit-workers.ts +++ b/scripts/unit-workers.ts @@ -195,12 +195,10 @@ export async function runUnitWorkers(root: string, files: readonly string[], chr const { dev, ino } = statSync(join(workspace, "node_modules/typescript/package.json")); return { worker: index + 1, workspace: relative(root, workspace), files, checkout, dependency: { dev, ino } }; }), null, 2)); - await runCommands(workers.map((worker, index) => ({ name: `unit worker ${index + 1} private package build`, - argv: [process.execPath, "run", "--cwd", worker.root, "build"] })), root, 2); for (const worker of workers) mkdirSync(join(worker.root, "tmp/maintenance-ledger"), { recursive: true, mode: 0o700 }); await runCommands(workers.map((worker, index) => ({ name: `complete unit worker ${index + 1}/${workers.length}`, argv: ["unshare", "--user", "--map-current-user", "--keep-caps", "--mount", "bash", join(root, "scripts/unit-worker-entry.sh"), - join(worker.root, "tmp/maintenance-ledger"), process.execPath, "run", "--cwd", worker.root, "test", ...worker.files.map(file => join(worker.root, file)), + join(worker.root, "tmp/maintenance-ledger"), process.execPath, "run", "--cwd", worker.root, "test", "--internal-full-preparation", ...worker.files.map(file => join(worker.root, file)), "--reporter=junit", "--reporter-outfile=tmp/unit-report.xml", "--timings=tmp/unit-timings.json", "--update-timings"], env: { BRAIN_REQUIRE_CHROME: "1", ...(chrome ? { PUPPETEER_EXECUTABLE_PATH: chrome } : {}) } })), root, 2); const reports = workers.map(worker => { diff --git a/scripts/workspace-lease.d.mts b/scripts/workspace-lease.d.mts index fa552ea60..32d81759a 100644 --- a/scripts/workspace-lease.d.mts +++ b/scripts/workspace-lease.d.mts @@ -1,3 +1,17 @@ export function ensureWorkspaceLease(root: string, mode: "read" | "write", entry?: "test"): Promise; export function inheritWorkspaceLease(options: T, defaultStderr?: "pipe" | "inherit", pipeOnly?: boolean): T; export function workspaceTestAccess(): "read" | "write"; +export function processIdentity(pid: number): { parent: number; start: string } | undefined; +export function liveAncestor(ownerPid: number, ownerStart: string): boolean; +export interface WorkspaceLeaseIdentity { + mode: "read" | "write"; + root: string; + rootDev: number; + rootIno: number; + dev: number; + ino: number; + ownerPid: number; + ownerFd: number; + ownerStart: string; +} +export function workspaceLeaseIdentity(root: string): WorkspaceLeaseIdentity | undefined; diff --git a/scripts/workspace-lease.mjs b/scripts/workspace-lease.mjs index ac1abdb47..43c32e71a 100644 --- a/scripts/workspace-lease.mjs +++ b/scripts/workspace-lease.mjs @@ -1,5 +1,5 @@ /** Coordinate development commands which share mutable workspace output. */ -import { fstatSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { fstatSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { constants } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -41,6 +41,48 @@ function ancestorOwnsLease(lease) { return false; } +/** Linux process lifetime, including PID reuse and zombie rejection. */ +export function processIdentity(pid) { + try { + if (!Number.isSafeInteger(pid) || pid < 1) return; + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + if (["Z", "X"].includes(fields[0])) return; + return { parent: Number(fields[1]), start: fields[19] }; + } catch { /* A dead owner has no authority. */ } +} + +export function liveAncestor(ownerPid, ownerStart) { + if (process.platform !== "linux" || typeof ownerStart !== "string" || !/^\d+$/.test(ownerStart)) return false; + let pid = process.pid; + for (let depth = 0; pid > 0 && depth < 128; depth++) { + const identity = processIdentity(pid); + if (!identity) return false; + if (pid === ownerPid) return identity.start === ownerStart; + pid = identity.parent; + } + return false; +} + +/** Strict invocation admission, in addition to ordinary inherited lock transport. */ +export function workspaceLeaseIdentity(root) { + try { + const lease = inheritedLease(); + if (!lease || !liveAncestor(lease.ownerPid, lease.ownerStart)) return; + const path = realpathSync(root), st = statSync(path), lock = statSync(lockPath(path)); + if (lease.root !== path || lease.rootDev !== st.dev || lease.rootIno !== st.ino || lock.dev !== lease.dev || lock.ino !== lease.ino) return; + const held = statSync(`/proc/${lease.ownerPid}/fd/${lease.ownerFd}`); + if (!held.isFile() || held.dev !== lock.dev || held.ino !== lock.ino) return; + const info = readFileSync(`/proc/${lease.ownerPid}/fdinfo/${lease.ownerFd}`, "utf8"); + // The path/inode alone survives release. Require an actual held flock and + // its original owner lifetime; a readable marker never grants write access. + const mode = lease.mode === "write" ? "WRITE" : "(?:READ|WRITE)"; + if (!new RegExp(`^lock:\\s+\\d+: FLOCK\\s+ADVISORY\\s+${mode}\\s+${lease.ownerPid}\\s+`, "m").test(info)) return; + return { mode: lease.mode, root: path, rootDev: st.dev, rootIno: st.ino, + dev: lock.dev, ino: lock.ino, ownerPid: lease.ownerPid, ownerFd: lease.ownerFd, ownerStart: lease.ownerStart }; + } catch { /* Unsupported/missing/replaced ownership cannot attest a build. */ } +} + function sameFile(fd, path) { try { const held = fstatSync(fd), wanted = statSync(path); @@ -174,7 +216,9 @@ async function enter(root, mode, lifetimeFd, executable, args) { } if (fd === undefined) throw new Error("Workspace lock descriptor was not inherited from flock"); const held = fstatSync(fd); - const owner = { ownerPid: process.pid, ownerFd: fd }; + const canonical = realpathSync(root), scope = statSync(canonical); + const owner = { ownerPid: process.pid, ownerFd: fd, ownerStart: processIdentity(process.pid)?.start, + root: canonical, rootDev: scope.dev, rootIno: scope.ino }; process.env[MARKER] = JSON.stringify({ mode, fd, dev: held.dev, ino: held.ino, ...owner }); const { spawn } = await import("node:child_process"); const { Socket } = await import("node:net"); diff --git a/tests/helpers/test-invocation-fixture.ts b/tests/helpers/test-invocation-fixture.ts new file mode 100644 index 000000000..26a75ba1e --- /dev/null +++ b/tests/helpers/test-invocation-fixture.ts @@ -0,0 +1,51 @@ +/** Real compiler/output/Node consumers in private, byte-owned checkouts. */ +import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +const source = resolve(import.meta.dir, "../.."); +export function invocationFixture(parent = tmpdir()) { + const root = mkdtempSync(join(parent, "odysseus-test-invocation-")); + function write(path: string, bytes: string) { mkdirSync(dirname(join(root, path)), { recursive: true }); writeFileSync(join(root, path), bytes); } + write("package.json", JSON.stringify({ name: "odysseus-invocation", private: true, type: "module", scripts: { test: "bun scripts/test.ts --timeout 30000" } })); + write("bun.lock", "{}\n"); + write(".gitignore", "node_modules/\npackages/*/dist/\ntmp/\n"); + write("tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2019", module: "ESNext", moduleResolution: "Bundler", types: [], skipLibCheck: true } })); + for (const file of ["build.ts", "build-execution.ts", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs", "test.ts", "test-invocation.ts", "runtime-test-builds.ts", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "unit-worker-entry.sh"]) { + write(`scripts/${file}`, readFileSync(join(source, "scripts", file), "utf8")); + } + for (const file of ["packages/core/tests/cli-harness.ts", "packages/ui-react/tests/fixtures/runtime-package-build.ts", "packages/ui-react/tests/fixtures/runtime-hook-phases.ts"]) write(file, readFileSync(join(source, file), "utf8")); + cpSync(join(source, "node_modules/typescript"), join(root, "node_modules/typescript"), { recursive: true }); + write("node_modules/.bin/tsc", `#!${process.execPath}\nconst child=Bun.spawn([process.execPath,new URL("../typescript/bin/tsc",import.meta.url).pathname,...process.argv.slice(2)],{stdout:"inherit",stderr:"inherit"});process.exitCode=await child.exited;\n`); + chmodSync(join(root, "node_modules/.bin/tsc"), 0o755); + for (const name of ["ithaca", "troy"]) { + write(`packages/${name}/package.json`, JSON.stringify({ name: `@odysseus/${name}`, version: "1.0.0", type: "module" })); + write(`packages/${name}/src/index.ts`, 'export const cargo = 1; export const port = (value?: { name?: string }) => value?.name ?? "Ithaca";\n'); + write(`packages/${name}/tsconfig.build.json`, JSON.stringify({ extends: "../../tsconfig.json", compilerOptions: { outDir: "dist", rootDir: "src", declaration: true }, include: ["src"] })); + } + // The production coordinator with an actual child, and continuous real flock. + write("owner.ts", `import {ensureWorkspaceLease,inheritWorkspaceLease} from "./scripts/workspace-lease.mjs"; +import {clearInvocation,prepareInvocation} from "./scripts/test-invocation"; +const code=await ensureWorkspaceLease(import.meta.dir,"write");if(code!==undefined)process.exit(code); +clearInvocation();let active;let cancelled=false; +for(const signal of ["SIGINT","SIGTERM","SIGHUP"])process.on(signal,()=>{cancelled=true;active?.kill(signal)}); +const preparation=await prepareInvocation(import.meta.dir,(process.env.ONLY||"ithaca,troy").split(","),child=>{active=child}); +try { if(preparation.code||cancelled)process.exitCode=preparation.code||1;else { +active=Bun.spawn([process.execPath,"probe.ts"],inheritWorkspaceLease({stdio:["inherit","inherit","inherit"]}));process.exitCode=await active.exited; +}}finally{preparation.dispose()} +`); + return { root, write }; +} + +export const consumerProbe = `import {expect} from "bun:test"; +import {readFileSync,writeFileSync,openSync,closeSync,renameSync,mkdirSync,cpSync,rmSync} from "node:fs"; +import {join} from "node:path"; +import {createHash} from "node:crypto"; +import {runtimePackageBuild} from "./packages/ui-react/tests/fixtures/runtime-package-build"; +import {runtimeHookPhases} from "./packages/ui-react/tests/fixtures/runtime-hook-phases"; +import {invocationBuild} from "./scripts/test-invocation"; +const root=import.meta.dir; +const hook=()=>runtimePackageBuild(root,["ithaca"],runtimeHookPhases("odysseus")); +const source=(marker)=>writeFileSync(join(root,"packages/ithaca/src/index.ts"),"export const cargo = "+marker+";\\n"); +const consume=()=>{const child=Bun.spawnSync(["node","--input-type=module","-e",'import {cargo} from '+JSON.stringify(join(root,"packages/ithaca/dist/index.js"))+';console.log(cargo)'],{stdout:"pipe",stderr:"pipe"});expect(child.exitCode,new TextDecoder().decode(child.stderr)).toBe(0);return Number(new TextDecoder().decode(child.stdout).trim());}; +const replaceRecord=(edit)=>{const locator=JSON.parse(process.env.BRAIN_TEST_BUILD_AUTHORITY);const path='/proc/'+locator.ownerPid+'/fd/'+locator.fd;const record=JSON.parse(readFileSync(path,'utf8'));edit(record);const bytes=JSON.stringify(record);const fd=openSync(path,'w');try{writeFileSync(fd,bytes)}finally{closeSync(fd)}locator.sha256=createHash('sha256').update(bytes).digest('hex');process.env.BRAIN_TEST_BUILD_AUTHORITY=JSON.stringify(locator);}; +`; diff --git a/tests/helpers/unit-worker-fixture.ts b/tests/helpers/unit-worker-fixture.ts index c64465b8c..9770bccd5 100644 --- a/tests/helpers/unit-worker-fixture.ts +++ b/tests/helpers/unit-worker-fixture.ts @@ -8,7 +8,6 @@ export function unitWorkerFixture(options: { failBuild?: boolean; diagnostics?: mkdirSync(join(root, "tmp")); const lease = join(source, "scripts/workspace-lease.mjs"); const receipt = join(source, "scripts/build-receipt.ts"); - const guard = join(source, "scripts/test-network-child-preload.ts"); const budget = join(source, "packages/ui-sdk/src/server/maintenance-temp-budget.ts"); const write = (file: string, content: string) => { mkdirSync(dirname(join(root, file)), { recursive: true }); @@ -17,6 +16,7 @@ export function unitWorkerFixture(options: { failBuild?: boolean; diagnostics?: write("scripts/unit-worker-entry.sh", readFileSync(join(source, "scripts/unit-worker-entry.sh"), "utf8")); write(".gitignore", "node_modules/\ntmp/\ndist/\n.vitest-attachments/\n"); write("package.json", JSON.stringify({ name: "odysseus-unit-fixture", type: "module", scripts: { build: "bun scripts/build.ts", test: "bun scripts/test.ts" } })); + write("packages/raft/package.json", JSON.stringify({ name: "@odysseus/raft", version: "1.0.0" })); write("packages/raft/README.md", "Odysseus builds the raft.\n"); // This byte-owned stand-in tests dependency copying, not a compiler version. write("node_modules/typescript/package.json", '{"name":"odysseus-dependency-fixture"}\n'); @@ -63,11 +63,11 @@ const {basename,join}=await import("node:path");const {existsSync}=await import( const shared=${JSON.stringify(join(root,"tmp"))};writeFileSync(join(shared,"built-"+basename(process.cwd())),"ready"); const stop=Date.now()+5000;while((!existsSync(join(shared,"built-1"))||!existsSync(join(shared,"built-2")))&&Date.now() `tests/${name}.test.ts`); for (const [index, file] of files.entries()) write(file, `import {expect,test} from "bun:test"; import {existsSync,mkdirSync,readFileSync,statSync,writeFileSync} from "node:fs"; @@ -86,6 +86,10 @@ test("${index === 0 ? "Odysseus" : "Penelope"} owns build, lease and maintenance expect(existsSync("dist/ready.txt"),"PRIVATE_UNIT_BUILD_READY").toBe(true); expect(readFileSync("dist/ready.txt","utf8")).toBe("Odysseus builds the raft."); const lease=JSON.parse(process.env.BRAIN_WORKSPACE_LEASE!); + const prepared=JSON.parse(readFileSync("dist/lease.json","utf8")); + expect(prepared.lease.ownerPid,"PREPARATION_AND_TEST_SHARE_LIVE_LEASE").toBe(lease.ownerPid); + expect(prepared.lease.ownerStart).toBe(lease.ownerStart); + expect(prepared.nonce,"PREPARATION_AND_TEST_SHARE_INVOCATION").toBe(process.env.BRAIN_TEST_INVOCATION_NONCE); expect(lease.ino,"PRIVATE_UNIT_OUTPUT_LEASE").toBe(statSync("tmp/workspace-operation/output.lock").ino); const git=Bun.spawnSync(["git","rev-parse","--path-format=absolute","--git-common-dir"],{stdout:"pipe",stderr:"pipe"});expect(git.exitCode).toBe(0); const parent=dirname(new TextDecoder().decode(git.stdout).trim()),shared=join(parent,"tmp/shared");mkdirSync(shared,{recursive:true}); diff --git a/tests/runtime-build-selection.test.ts b/tests/runtime-build-selection.test.ts new file mode 100644 index 000000000..17adb72a5 --- /dev/null +++ b/tests/runtime-build-selection.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { runtimeBuildInventory, runtimeBuildSelection } from "../scripts/runtime-test-builds"; +import { selectBuildPackages } from "../scripts/publishable-packages"; +import { testArgv, testPaths } from "../scripts/test"; +const root = resolve(import.meta.dir, ".."); + +test("every runtime package hook has one inventoried actual forward closure and no boolean bypass", async () => { + const directory = join(root, "packages/ui-react/tests"); + const hooks = readdirSync(directory).filter(name => name.endsWith(".test.ts") && readFileSync(join(directory, name), "utf8").includes("runtimePackageBuild(")); + expect(hooks).toHaveLength(17); + expect(hooks.map(name => `packages/ui-react/tests/${name}`).sort()).toEqual(Object.keys(runtimeBuildInventory).sort()); + for (const file of hooks) { + const text = readFileSync(join(directory, file), "utf8"); + expect(text).not.toMatch(/BRAIN_(?:JOURNAL|ARTIFACT)_DEPENDENCIES_BUILT|BRAIN_AUTH_FIXTURE_BUILT|scripts\/build\.ts/); + const match = /runtimePackageBuild\([^\n]*?, (\[[^\]]+\])/.exec(text); + expect(match, file).not.toBeNull(); + const packages = JSON.parse(match![1]!); + expect(packages, file).toEqual(runtimeBuildInventory[`packages/ui-react/tests/${file}`]); + const child = Bun.spawn([process.execPath, "scripts/build.ts", "--dry-run", "--for", packages.join(",")], { cwd: root, stdout: "pipe", stderr: "pipe" }); + const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(code, err).toBe(0); + expect(out.trim().split("\n")).toEqual(selectBuildPackages(root, packages).map(pkg => `Would build packages/${pkg.dir}`)); + } +}); + +test("build selection shares Bun positional parsing without changing test argv", () => { + const full = ["ui-react", "ui-server", "core"]; + for (const args of [["external-speech"], ["packages/ui-react/tests"], ["./packages"], [join(root, "packages/ui-react/tests/external-speech-runtime.test.ts")], ["--", "external-speech"], ["--changed"], ["--shard=1/4"]]) { + const argv = testArgv(args); + expect(selectBuildPackages(root, runtimeBuildSelection(root, root, testPaths(argv))).map(pkg => pkg.dir)).toEqual(selectBuildPackages(root, full).map(pkg => pkg.dir)); + } + for (const args of [["tests/test-script.test.ts", "-t", "external-speech"], ["tests/test-script.test.ts", "--preload", "external-speech"], ["tests/test-script.test.ts", "--timeout", "180000"], ["does-not-exist.test.ts"]]) { + expect(testArgv(args)).toEqual(args); + expect(runtimeBuildSelection(root, root, testPaths(testArgv(args)))).toEqual([]); + } + expect(runtimeBuildSelection(root, root, ["share-draft-runtime"])).toEqual(["ui-sdk"]); + expect(runtimeBuildSelection(root, root, ["haiku-model-runtime"])).toEqual(["ui-react"]); +}); diff --git a/tests/test-invocation-lifetime.test.ts b/tests/test-invocation-lifetime.test.ts new file mode 100644 index 000000000..6608bec2f --- /dev/null +++ b/tests/test-invocation-lifetime.test.ts @@ -0,0 +1,177 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { consumerProbe, invocationFixture } from "./helpers/test-invocation-fixture"; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function fixture() { const value = invocationFixture(); roots.push(value.root); return value; } +async function until(check: () => boolean, label: string) { + const end = Date.now() + 10_000; + while (!check() && Date.now() < end) await Bun.sleep(10); + expect(check(), label).toBe(true); +} +function stopOwnedGroup(pid: number) { + try { process.kill(-pid, "SIGKILL"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } +} +function start(root: string, args = ["owner.ts"]) { + const child = Bun.spawn([process.execPath, ...args], { cwd: root, env: { ...process.env, ONLY: "ithaca" }, stdout: "pipe", stderr: "pipe" }); + let output = ""; + const drain = async (stream: ReadableStream) => { for await (const bytes of stream) output += new TextDecoder().decode(bytes); }; + const done = Promise.all([child.exited, drain(child.stdout), drain(child.stderr)]).then(([code]) => ({ code, output })); + return { child, done, output: () => output }; +} + +test("preparation waits for real delayed stdout and stderr EOF after builder exit", async () => { + const { root, write } = fixture(); + const compiler = readFileSync(join(root, "node_modules/.bin/tsc"), "utf8"); + write("node_modules/.bin/tsc", compiler + ` +Bun.spawn([process.execPath,"hold.ts"],{stdout:"inherit",stderr:"inherit"});process.exit(process.exitCode||0); +`); + write("hold.ts", `import {existsSync,writeFileSync} from "node:fs";writeFileSync("tmp/eof-holder",String(process.pid));const end=Date.now()+10000;while(!existsSync("tmp/release-eof")&&Date.now() running.output().includes('"event":"build-exit"'), "BUILDER_EXIT_OBSERVED"); + expect(running.output(), "NO_EARLY_AUTHORITY_AT_EXIT").not.toContain('"event":"build-attested"'); + expect(running.output()).not.toContain("TESTS_STARTED"); + writeFileSync(join(root, "tmp/release-eof"), "release"); + const result = await running.done; expect(result.code, result.output).toBe(0); + expect(result.output).toContain("delayed stdout"); expect(result.output).toContain("delayed stderr"); + for (const channel of ["stdout", "stderr"]) expect(result.output.indexOf(`"event":"${channel}-complete"`)).toBeLessThan(result.output.indexOf('"event":"build-attested"')); + } finally { running.child.kill(); await running.done; } +}); + +test("cancellation during actual preparation prevents tests and cleans the owned compiler", async () => { + const { root, write } = fixture(); + write("node_modules/.bin/tsc", `#!${process.execPath}\nimport {writeFileSync} from "node:fs";writeFileSync("tmp/compiler-pid",String(process.pid));await Bun.sleep(30000);process.exit(0);\n`); + write("sentinel.test.ts", 'import {test} from "bun:test";test("must not start",()=>console.log("TESTS_STARTED"));'); + const running = start(root, ["run", "test", "--internal-full-preparation", "sentinel.test.ts"]); + try { + await until(() => existsSync(join(root, "tmp/compiler-pid")), "COMPILER_STARTED"); + const pid = Number(readFileSync(join(root, "tmp/compiler-pid"), "utf8")); + running.child.kill("SIGTERM"); + const result = await running.done; expect(result.code, result.output).not.toBe(0); + expect(result.output).not.toContain("TESTS_STARTED"); expect(result.output).not.toContain('"event":"build-attested"'); + await until(() => !existsSync(`/proc/${pid}`) || /\) Z /.test(readFileSync(`/proc/${pid}/stat`, "utf8")), "OWNED_COMPILER_STOPPED"); + // The same checkout is admitted by a fresh writer after cancellation. + const lock = Bun.spawn([process.execPath, "-e", 'import {ensureWorkspaceLease} from "./scripts/workspace-lease.mjs";const code=await ensureWorkspaceLease(process.cwd(),"write");if(code!==undefined)process.exit(code);console.log("NEW_WRITER_ADMITTED");'], { cwd: root, stdout: "pipe", stderr: "pipe" }); + const [code, out, err] = await Promise.all([lock.exited, new Response(lock.stdout).text(), new Response(lock.stderr).text()]); + expect(code, out + err).toBe(0); expect(out).toContain("NEW_WRITER_ADMITTED"); + } finally { running.child.kill(); await running.done; } +}); + +test("abrupt invocation owner death revokes receipt while an explicitly owned descendant survives", async () => { + const { root, write } = fixture(); + write("survivor.ts", consumerProbe + `const end=Date.now()+10000;while(!require("fs").existsSync("tmp/owner-died")&&Date.now() existsSync(join(root, "tmp/survivor-pid")), "SURVIVOR_STARTED"); + survivor = Number(readFileSync(join(root, "tmp/survivor-pid"), "utf8")); + const result = await running.done; expect(result.code).not.toBe(0); + writeFileSync(join(root, "tmp/owner-died"), "release"); + await until(() => existsSync(join(root, "tmp/survivor-done")), "SURVIVOR_ACTUAL_FALLBACK: " + (existsSync(join(root, "tmp/survivor.log")) ? readFileSync(join(root, "tmp/survivor.log"), "utf8") : "")); + expect(readFileSync(join(root, "tmp/survivor-done"), "utf8")).toBe("done"); + } finally { + running.child.kill(); await running.done; + if (survivor) stopOwnedGroup(survivor); + } +}); + +test("a real receipt open failure removes only owned scratch and leaves hooks rebuilding", async () => { + const { root, write } = fixture(); + write("receipt-fault.ts", `import fs from "node:fs";import {mock} from "bun:test"; +const open=fs.openSync;let injected=false; +mock.module("node:fs",()=>({...fs,openSync:(path,flags,...args)=>{ + if(!injected&&typeof path==="string"&&path.includes("/brain-test-invocation-")&&path.endsWith("/receipt")&&!(flags&(fs.constants.O_WRONLY|fs.constants.O_RDWR))){ + injected=true;fs.writeFileSync(${JSON.stringify(join(root, "scratch-path"))},path);fs.chmodSync(path,0); + try{return open(path,flags,...args)}catch(error){fs.writeFileSync(${JSON.stringify(join(root, "scratch-error"))},error.code);throw error} + } + return open(path,flags,...args); +}}));`); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeUndefined();source(2);await hook();expect(consume(),"RECEIPT_FAILURE_FALLBACK_REACHES_CONSUMER").toBe(2);`); + const running = start(root, ["--preload", join(root, "receipt-fault.ts"), "owner.ts"]); + try { + const result = await running.done; + expect(existsSync(join(root, "scratch-error")), result.output).toBe(true); + expect(readFileSync(join(root, "scratch-error"), "utf8"), "REAL_KERNEL_RECEIPT_OPEN_ERROR").toBe("EACCES"); + const scratch = readFileSync(join(root, "scratch-path"), "utf8"); + expect(result.code, "RECEIPT_FAILURE_FALLBACK_REACHES_CONSUMER: " + result.output).toBe(0); + expect(existsSync(scratch), "OWNED_FAILED_RECEIPT_REMOVED").toBe(false); + expect(existsSync(join(scratch, "..")), "OWNED_EMPTY_SCRATCH_REMOVED").toBe(false); + } finally { + running.child.kill(); await running.done; + if (existsSync(join(root, "scratch-path"))) rmSync(join(readFileSync(join(root, "scratch-path"), "utf8"), ".."), { recursive: true, force: true }); + } +}); + +for (const damage of ["unrelated-entry", "replaced-receipt"] as const) test(`receipt cleanup retains ${damage} and fails before tests`, async () => { + const { root, write } = fixture(); + write("receipt-fault.ts", `import fs from "node:fs";import {mock} from "bun:test"; +const open=fs.openSync;let injected=false; +mock.module("node:fs",()=>({...fs,openSync:(path,flags,...args)=>{ + if(!injected&&typeof path==="string"&&path.includes("/brain-test-invocation-")&&path.endsWith("/receipt")&&!(flags&(fs.constants.O_WRONLY|fs.constants.O_RDWR))){ + injected=true;fs.writeFileSync(${JSON.stringify(join(root, "scratch-path"))},path); + ${damage === "unrelated-entry" ? 'fs.writeFileSync(path+".unrelated","Penelope retains this entry");fs.chmodSync(path,0);' : 'fs.renameSync(path,path+".original");fs.writeFileSync(path,"Penelope retains this replacement");'} + } + return open(path,flags,...args); +}}));`); + write("probe.ts", 'console.log("TESTS_STARTED");'); + const running = start(root, ["--preload", join(root, "receipt-fault.ts"), "owner.ts"]); + try { + const result = await running.done; + expect(result.code, result.output).not.toBe(0); + expect(result.output).toContain(damage === "unrelated-entry" ? "ENOTEMPTY" : "Replaced invocation receipt; retained scratch"); + expect(result.output).not.toContain("TESTS_STARTED"); + expect(result.output).not.toContain('"event":"build-attested"'); + const path = readFileSync(join(root, "scratch-path"), "utf8"); + if (damage === "unrelated-entry") { + expect(existsSync(path), "OWNED_RECEIPT_REMOVED_DESPITE_UNRELATED_ENTRY").toBe(false); + expect(readFileSync(path + ".unrelated", "utf8"), "UNRELATED_ENTRY_RETAINED").toBe("Penelope retains this entry"); + } else { + expect(readFileSync(path, "utf8"), "REPLACEMENT_INODE_RETAINED").toBe("Penelope retains this replacement"); + } + } finally { + running.child.kill(); await running.done; + // These injected entries belong to this control, never production cleanup. + if (existsSync(join(root, "scratch-path"))) rmSync(join(readFileSync(join(root, "scratch-path"), "utf8"), ".."), { recursive: true, force: true }); + } +}); + +test("cancelling a nested preparation stops its compiler while the outer invocation stays live", async () => { + const { root, write } = fixture(); + write("nested.test.ts", 'import {test} from "bun:test";test("must not start",()=>console.log("NESTED_TESTS_STARTED"));'); + write("probe.ts", consumerProbe + ` +writeFileSync("node_modules/.bin/tsc",'#!'+process.execPath+'\\nimport {writeFileSync} from "node:fs";writeFileSync("tmp/nested-compiler",String(process.pid));await Bun.sleep(30000);'); +const child=Bun.spawn([process.execPath,"run","test","--internal-full-preparation","nested.test.ts"],{stdout:"pipe",stderr:"pipe"}); +const output=Promise.all([new Response(child.stdout).text(),new Response(child.stderr).text()]); +const end=Date.now()+10000;while(!require("fs").existsSync("tmp/nested-compiler")&&Date.now(){try{return !/\\) [ZX] /.test(readFileSync('/proc/'+compiler+'/stat','utf8'))}catch{return false}};while(live()&&Date.now() { + const { root, write } = fixture(); + write("expected", "1"); + const watched = consumerProbe + `import {test} from "bun:test";test("watch consumer",async()=>{expect(invocationBuild(root,["ithaca"]),"WATCH_NEVER_BORROWS_AUTHORITY").toBeUndefined();await hook();const marker=consume();expect(marker,"WATCH_RERUN_FRESH_CONSUMER").toBe(Number(readFileSync("expected","utf8")));console.log("WATCH_CONSUMER",marker);});`; + write("watched.test.ts", watched); + const args = entry === "wrapper" ? ["run", "test", "--watch", "watched.test.ts"] : ["test", "--watch", "--preload", join(root, "scripts/test-network-preload.ts"), "watched.test.ts"]; + write("probe.ts", `import {existsSync} from "node:fs"; +const child=Bun.spawn([process.execPath,...${JSON.stringify(args)}],{stdout:"inherit",stderr:"inherit"});const end=Date.now()+15000;while(!existsSync("tmp/stop-watch")&&Date.now() running.output().includes("WATCH_CONSUMER 1"), "FIRST_WATCH_CONSUMER: " + running.output()); + write("expected", "2"); write("packages/ithaca/src/index.ts", "export const cargo = 2;\n"); + write("watched.test.ts", watched + "\n// Odysseus prepares another voyage.\n"); + await until(() => running.output().includes("WATCH_CONSUMER 2"), "SECOND_WATCH_CONSUMER: " + running.output()); + write("tmp/stop-watch", "stop"); + const result = await running.done; expect(result.code, result.output).toBe(0); expect(result.output).not.toContain("(fail)"); + } finally { write("tmp/stop-watch", "stop"); running.child.kill(); await running.done; } +}); diff --git a/tests/test-invocation-workers.test.ts b/tests/test-invocation-workers.test.ts new file mode 100644 index 000000000..31ae6ae5a --- /dev/null +++ b/tests/test-invocation-workers.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { invocationFixture } from "./helpers/test-invocation-fixture"; +import { runUnitWorkers } from "../scripts/unit-workers"; + +test("two native private workers attest actual independent builds and reject sibling receipt transplants", async () => { + const { root, write } = invocationFixture(dirname(resolve(import.meta.dir, ".."))); + const reports = join(root, "tmp/reports"), shared = join(root, "tmp/shared"); mkdirSync(shared, { recursive: true }); + const files = ["tests/odysseus.test.ts", "tests/penelope.test.ts"]; + for (const [index, file] of files.entries()) write(file, `import {test,expect} from "bun:test"; +import {readFileSync,writeFileSync,existsSync} from "node:fs"; +import {join} from "node:path"; +import {invocationBuild} from "../scripts/test-invocation"; +import {runtimePackageBuild} from "../packages/ui-react/tests/fixtures/runtime-package-build"; +import {runtimeHookPhases} from "../packages/ui-react/tests/fixtures/runtime-hook-phases"; +test("independent prepared worker ${index}",async()=>{ +const root=process.cwd(),shared=${JSON.stringify(shared)}; +const receipt=invocationBuild(root,["ithaca","troy"]);expect(receipt,"PRIVATE_WORKER_ACTUAL_SUCCESSFUL_AUTHORITY").toBeDefined(); +const own={authority:process.env.BRAIN_TEST_BUILD_AUTHORITY,nonce:process.env.BRAIN_TEST_INVOCATION_NONCE,lease:receipt.lease,build:receipt.build.pid}; +writeFileSync(join(shared,"${index}.json"),JSON.stringify(own)); +await runtimePackageBuild(root,["ithaca"],runtimeHookPhases("private-worker-${index}")); +const end=Date.now()+10000;while(!existsSync(join(shared,"${1-index}.json"))&&Date.now() { const result = Bun.spawnSync(["git", "-C", root, ...args], { stdout: "pipe", stderr: "pipe" }); expect(result.exitCode, new TextDecoder().decode(result.stderr)).toBe(0); }; + git("init", "-q"); git("add", "."); git("-c", "user.name=Odysseus", "-c", "user.email=odysseus@example.invalid", "commit", "-qm", "Private invocation fixture"); + try { + await runUnitWorkers(root, files, undefined, reports); + const receipts = readdirSync(reports).filter(file => /-build-.*-probe.json$/.test(file)).map(file => JSON.parse(readFileSync(join(reports, file), "utf8"))); + expect(receipts).toHaveLength(4); + expect(receipts.filter(receipt => receipt.mode === "full"), "ONE_ACTUAL_FULL_PREPARATION_PER_WORKER").toHaveLength(2); + for (const receipt of receipts) expect(receipt.success).toBe(true); + const workers = JSON.parse(readFileSync(join(reports, "unit-worker-checkouts.json"), "utf8")); + expect(new Set(workers.map((worker: { dependency: { ino: number } }) => worker.dependency.ino)).size).toBe(2); + for (const worker of workers) expect(existsSync(join(root, worker.workspace)), "PRIVATE_WORKER_CLEANED").toBe(false); + } finally { rmSync(root, { recursive: true, force: true }); } +}, 60_000); diff --git a/tests/test-invocation.test.ts b/tests/test-invocation.test.ts new file mode 100644 index 000000000..e381090c1 --- /dev/null +++ b/tests/test-invocation.test.ts @@ -0,0 +1,143 @@ +import { afterEach, expect, test } from "bun:test"; +import { readFileSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { consumerProbe, invocationFixture } from "./helpers/test-invocation-fixture"; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function fixture() { const value = invocationFixture(); roots.push(value.root); return value; } +async function run(root: string, args = ["owner.ts"], env: NodeJS.ProcessEnv = {}) { + const child = Bun.spawn([process.execPath, ...args], { cwd: root, env: { ...process.env, ...env }, stdout: "pipe", stderr: "pipe" }); + const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + return { code, output: out + err }; +} +function receipts(root: string) { return readdirSync(join(root, "tmp")).filter(file => /^build-.*-probe.json$/.test(file)).map(file => JSON.parse(readFileSync(join(root, "tmp", file), "utf8"))); } + +test("multiple real hooks consume one completed invocation build", async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"]),"LIVE_BUILD_AUTHORITY").toBeDefined();await hook();source(2);await hook();expect(consume(),"MIDRUN_EDIT_WAITS_FOR_NEXT_INVOCATION").toBe(1);`); + const result = await run(root); expect(result.code, result.output).toBe(0); + expect(receipts(root), "ONE_ACTUAL_COMPILER_SET").toHaveLength(1); + expect(result.output.match(/"disposition":"attested"/g)).toHaveLength(4); + write("probe.ts", consumerProbe + `await hook();expect(consume(),"NEXT_INVOCATION_REACHES_CONSUMER").toBe(2);`); + const next = await run(root); expect(next.code, next.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); + +for (const [name, damage] of Object.entries({ + absent: 'delete process.env.BRAIN_TEST_BUILD_AUTHORITY;', + malformed: 'process.env.BRAIN_TEST_BUILD_AUTHORITY="{";', + nonce: 'process.env.BRAIN_TEST_INVOCATION_NONCE="00000000-0000-0000-0000-000000000000";', + "reused-owner-pid": 'const loc=JSON.parse(process.env.BRAIN_TEST_BUILD_AUTHORITY);loc.ownerStart="0";process.env.BRAIN_TEST_BUILD_AUTHORITY=JSON.stringify(loc);', + "reused-owner-fd": 'const loc=JSON.parse(process.env.BRAIN_TEST_BUILD_AUTHORITY);loc.fd=3;process.env.BRAIN_TEST_BUILD_AUTHORITY=JSON.stringify(loc);', + "wrong-root-inode": 'replaceRecord(r=>r.root.ino++);', + "mismatched-lease": 'replaceRecord(r=>r.lease.ownerStart="0");', + "failed-receipt": 'replaceRecord(r=>r.build.success=false);', + "interrupted-receipt": 'replaceRecord(r=>r.build.state="running");', + "missing-package": 'replaceRecord(r=>r.build.packages=[]);', + "missing-compiler": 'replaceRecord(r=>r.build.packages[0].children=[]);', + "wrong-schema": 'replaceRecord(r=>r.version=2);', + "replaced-receipt": 'const loc=JSON.parse(process.env.BRAIN_TEST_BUILD_AUTHORITY);writeFileSync("/proc/"+loc.ownerPid+"/fd/"+loc.fd,"{}");', +})) test(`actual helper rebuilds after ${name} authority`, async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeDefined();source(2);${damage}await hook();expect(consume(),"INVALID_AUTHORITY_REBUILDS_CONSUMER").toBe(2);`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); + +test("a readable lease descriptor without its own held flock cannot attest a build", async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + ` +const lease=JSON.parse(process.env.BRAIN_WORKSPACE_LEASE),fd=openSync("tmp/workspace-operation/output.lock","r"); +const fields=readFileSync("/proc/self/stat","utf8").split(") ")[1].split(" "); +const marker={...lease,fd,ownerFd:fd,ownerPid:process.pid,ownerStart:fields[19]}; +expect(readFileSync("/proc/self/fdinfo/"+fd,"utf8")).not.toContain("FLOCK"); +process.env.BRAIN_WORKSPACE_LEASE=JSON.stringify(marker); +replaceRecord(r=>r.lease={...r.lease,ownerFd:fd,ownerPid:process.pid,ownerStart:fields[19]}); +try{source(2);await hook();expect(consume(),"UNHELD_DESCRIPTOR_CANNOT_SUPPRESS_COMPILER").toBe(2)}finally{closeSync(fd)} +`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); + +test("valid smaller closure cannot authorize another package", async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["troy"])).toBeDefined();await hook();expect(consume(),"CLOSURE_SUBSET_REBUILDS_CONSUMER").toBe(1);`); + const result = await run(root, ["owner.ts"], { ONLY: "troy" }); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); + +test("a live ancestor's other checkout receipt never suppresses the actual consumer build", async () => { + const first = fixture(), second = fixture(); + second.write("packages/ithaca/dist/index.js", "export const cargo=1;\n"); + second.write("probe.ts", consumerProbe + `source(2);await hook();expect(consume(),"CROSS_CHECKOUT_BUILD_REACHES_CONSUMER").toBe(2);`); + first.write("probe.ts", `const child=Bun.spawn([process.execPath,${JSON.stringify(join(second.root, "probe.ts"))}],{cwd:${JSON.stringify(second.root)},stdout:"inherit",stderr:"inherit"});process.exitCode=await child.exited;`); + const result = await run(first.root); expect(result.code, result.output).toBe(0); expect(receipts(second.root)).toHaveLength(1); +}); + +test("normal nested wrapper creates a new invocation beneath the same live lease", async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `source(2);const child=Bun.spawn([process.execPath,"run","test","--internal-full-preparation","nested.test.ts"],{stdout:"inherit",stderr:"inherit",env:{...process.env,PREVIOUS_NONCE:process.env.BRAIN_TEST_INVOCATION_NONCE,PREVIOUS_AUTHORITY:process.env.BRAIN_TEST_BUILD_AUTHORITY}});expect(await child.exited,"NESTED_WRAPPER").toBe(0);`); + write("nested.test.ts", consumerProbe + `import {test} from "bun:test";test("new invocation",async()=>{expect(process.env.BRAIN_TEST_INVOCATION_NONCE,"NEW_INVOCATION_SAME_LEASE").not.toBe(process.env.PREVIOUS_NONCE);expect(consume()).toBe(2);source(3);process.env.BRAIN_TEST_BUILD_AUTHORITY=process.env.PREVIOUS_AUTHORITY;await hook();expect(consume(),"REPLAYED_INVOCATION_REBUILDS_CONSUMER").toBe(3);});`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(3); +}); + +test("legacy bypass flags without authority still compile", async () => { + const { root, write } = fixture(); + write("packages/ithaca/dist/index.js", "export const cargo=0;\n"); + write("probe.ts", consumerProbe + `await hook();expect(consume(),"LEGACY_FLAGS_DO_NOT_SKIP_BUILD").toBe(1);`); + const result = await run(root, ["probe.ts"], { BRAIN_JOURNAL_DEPENDENCIES_BUILT: "1", BRAIN_ARTIFACT_DEPENDENCIES_BUILT: "1", BRAIN_AUTH_FIXTURE_BUILT: "1" }); + expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(1); +}); + +test("actual prebuild compiler failure prevents test execution without retry", async () => { + const { root, write } = fixture(); + write("packages/ithaca/src/index.ts", "export const cargo: string = 1;\n"); + write("sentinel.test.ts", 'import {test} from "bun:test";test("must not start",()=>console.log("TESTS_STARTED"));'); + const result = await run(root, ["run", "test", "--internal-full-preparation", "sentinel.test.ts"]); + expect(result.code, result.output).not.toBe(0); expect(result.output).not.toContain("TESTS_STARTED"); expect(receipts(root)).toHaveLength(1); + expect(receipts(root)[0].success).toBe(false); +}); + +for (const damage of ["missing", "malformed", "aliased", "wrong-builder-pid", "wrong-build-id", "wrong-closure"] as const) test(`successful preparation with ${damage} diagnostics leaves real hooks rebuilding`, async () => { + const { root, write } = fixture(); + const path = join(root, "scripts/build.ts"), original = readFileSync(path, "utf8"); + write("scripts/build.ts", original + ` +if(process.env.BRAIN_TEST_BUILD_ID){const fs=await import("node:fs");const file="tmp/build-"+process.pid+"-"+process.env.BRAIN_TEST_BUILD_ID+"-probe.json"; +${damage === "missing" ? 'fs.unlinkSync(file);' : damage === "malformed" ? 'fs.writeFileSync(file,"{");' : damage === "aliased" ? 'fs.renameSync(file,file+".saved");fs.symlinkSync(file.split("/").pop()+".saved",file);' : +`const receipt=JSON.parse(fs.readFileSync(file,"utf8"));${damage === "wrong-builder-pid" ? "receipt.pid++;" : damage === "wrong-build-id" ? 'receipt.id="00000000-0000-0000-0000-000000000000";' : "receipt.packages.pop();"}fs.writeFileSync(file,JSON.stringify(receipt));`}} +`); + // The hook is a different real builder and still runs even when both of its + // best-effort diagnostics are damaged too. Exit/output, not probe presence, + // prove actual compilation and the changed emitted consumer. + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeUndefined();source(2);await hook();expect(consume(),"DIAGNOSTIC_FAILURE_REBUILDS_CONSUMER").toBe(2);`); + const result = await run(root); expect(result.code, result.output).toBe(0); + expect(result.output).toContain('"event":"build-unattested"'); expect(result.output).toContain('"disposition":"built"'); +}); + +test("read-mode lease cannot reuse authority or delete existing output", async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `source(2);const lease=JSON.parse(process.env.BRAIN_WORKSPACE_LEASE);lease.mode="read";process.env.BRAIN_WORKSPACE_LEASE=JSON.stringify(lease);expect(invocationBuild(root,["ithaca"])).toBeUndefined();await expect(hook()).rejects.toThrow("Read-only verification cannot rebuild");expect(consume(),"READ_LEASE_PRESERVES_OUTPUT").toBe(1);`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(1); +}); + +test("ordinary explicit builders compile even with valid invocation authority", async () => { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeDefined();source(2);const child=Bun.spawn([process.execPath,"scripts/build.ts","--for","ithaca"],{stdout:"inherit",stderr:"inherit"});expect(await child.exited).toBe(0);expect(consume(),"EXPLICIT_BUILD_STILL_COMPILES").toBe(2);`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); + +test("fresh standalone invocations consume effective config alias and installed compiler changes", async () => { + const { root, write } = fixture(); + const { renameSync, symlinkSync, writeFileSync } = await import("node:fs"); + renameSync(join(root, "tsconfig.json"), join(root, "effective.json")); symlinkSync("effective.json", join(root, "tsconfig.json")); + write("consumer.test.ts", consumerProbe + `import {test} from "bun:test";test("fresh consumer",async()=>{await hook();expect(consume(),"STANDALONE_COMPILER_CHANGE_REACHES_CONSUMER").toBe(Number(process.env.EXPECTED));expect(readFileSync(join(root,"packages/ithaca/dist/index.js"),"utf8").includes("?."),"STANDALONE_ALIAS_REACHES_OUTPUT").toBe(process.env.MODERN==="1");});`); + const args = ["run", "test", "--internal-full-preparation", "consumer.test.ts"]; + let result = await run(root, args, { EXPECTED: "1" }); expect(result.code, result.output).toBe(0); + write("effective.json", readFileSync(join(root, "effective.json"), "utf8").replace("ES2019", "ES2022")); + const compiler = join(root, "node_modules/typescript/lib/_tsc.js"), original = readFileSync(compiler, "utf8"); + const anchor = "function emitNumericOrBigIntLiteral(node) {"; expect(original.split(anchor)).toHaveLength(2); + writeFileSync(compiler, original.replace(anchor, anchor + '\n if (node.text === "1") { writeStringLiteral("2"); return; }')); + result = await run(root, args, { EXPECTED: "2", MODERN: "1" }); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); + +test("replacing a checkout at the same pathname revokes the live build receipt", async () => { + const { root, write } = fixture(); roots.push(`${root}.previous`); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeDefined();renameSync(root,root+".previous");cpSync(root+".previous",root,{recursive:true});source(2);await hook();expect(consume(),"REPLACED_CHECKOUT_REBUILDS_CONSUMER").toBe(2);`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); +}); diff --git a/tests/test-script.test.ts b/tests/test-script.test.ts index b993d775a..324e11c4b 100644 --- a/tests/test-script.test.ts +++ b/tests/test-script.test.ts @@ -33,7 +33,7 @@ async function run(args: string[], env: Record = {}) { if (existsSync(script)) { mkdirSync(join(dir, "scripts")); cpSync(script, join(dir, "scripts/test.ts")); - for (const helper of ["test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { + for (const helper of ["runtime-test-builds.ts", "test-invocation.ts", "publishable-packages.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { cpSync(join(ROOT, "scripts", helper), join(dir, "scripts", helper)); } // The actual preload also owns cleanup for the shared CLI harness. diff --git a/tests/test-shards.test.ts b/tests/test-shards.test.ts index 81a3fd44d..9616dfc1f 100644 --- a/tests/test-shards.test.ts +++ b/tests/test-shards.test.ts @@ -59,7 +59,7 @@ describe("balanced shards run the root command for real", () => { mkdirSync(join(dir, "scripts")); const manifest = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "fixture", scripts: { test: manifest.scripts.test } })); - for (const script of ["test.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { + for (const script of ["test.ts", "runtime-test-builds.ts", "test-invocation.ts", "publishable-packages.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { cpSync(join(ROOT, "scripts", script), join(dir, "scripts", script)); } mkdirSync(join(dir, "packages/core/tests"), { recursive: true }); diff --git a/tests/unit-workers.test.ts b/tests/unit-workers.test.ts index 190a3d94f..4cac45fe8 100644 --- a/tests/unit-workers.test.ts +++ b/tests/unit-workers.test.ts @@ -122,7 +122,7 @@ const copy=fs.copyFileSync;mock.module("node:fs",()=>({...fs,copyFileSync:(sourc const child = Bun.spawn([process.execPath, "--preload", preload, "-e", ` import {runUnitWorkers} from ${JSON.stringify(join(import.meta.dir, "../scripts/unit-workers.ts"))}; let failure; try { await runUnitWorkers(${JSON.stringify(root)},${JSON.stringify(files)},undefined,${JSON.stringify(reports)}); } catch(error) { failure=error; } - if(!(failure instanceof AggregateError) || !failure.errors.some(error=>String(error).includes("private package build"))) throw Error("ORIGINAL_PREBUILD_FAILURE_RETAINED"); + if(!(failure instanceof AggregateError) || !failure.errors.some(error=>String(error).includes("complete unit worker"))) throw Error("ORIGINAL_PREBUILD_FAILURE_RETAINED"); console.log("COLLECTOR_FAILURE_EXPLICIT",failure.errors.length); `], { stdout: "pipe", stderr: "pipe" }); const [code, out, err] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); From 18cf3261f583c4ea9e6c3bfa43979e2e9606b6bc Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Tue, 20 Oct 2026 17:55:10 +0200 Subject: [PATCH 2/4] test(ci): include invocation helpers in copied wrappers --- tests/experiment-tier.test.ts | 2 +- tests/local-checks.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/experiment-tier.test.ts b/tests/experiment-tier.test.ts index 821c051a4..caf6cf474 100644 --- a/tests/experiment-tier.test.ts +++ b/tests/experiment-tier.test.ts @@ -16,7 +16,7 @@ import { } from "../scripts/test-experiments"; const ROOT = join(import.meta.dir, ".."); -const SCRIPTS = ["test.ts", "test-experiments.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", +const SCRIPTS = ["test.ts", "runtime-test-builds.ts", "test-invocation.ts", "publishable-packages.ts", "test-experiments.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]; const passing = (name: string) => `import { expect, test } from "bun:test";\ntest(${JSON.stringify(name)}, () => expect(1).toBe(1));\n`; diff --git a/tests/local-checks.test.ts b/tests/local-checks.test.ts index a22dcf2e5..25096b091 100644 --- a/tests/local-checks.test.ts +++ b/tests/local-checks.test.ts @@ -313,7 +313,7 @@ function importFixture() { const dir = fixture(); const root = join(import.meta.dir, ".."); mkdirSync(join(dir, "scripts")); - for (const helper of ["test.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { + for (const helper of ["test.ts", "runtime-test-builds.ts", "test-invocation.ts", "publishable-packages.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { cpSync(join(root, "scripts", helper), join(dir, "scripts", helper)); } mkdirSync(join(dir, "packages/core/tests"), { recursive: true }); From b751f30becd03af1b506e34f4f62f0cb155b83d3 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Tue, 20 Oct 2026 18:40:22 +0200 Subject: [PATCH 3/4] fix(test): retain invocation ownership after command leader exit --- docs/decisions/ci-utility.md | 12 +++ scripts/test-invocation-child.ts | 131 +++++++++++++++++++++++ scripts/test-invocation.ts | 56 ++-------- scripts/test.ts | 13 +-- tests/experiment-tier.test.ts | 2 +- tests/helpers/test-invocation-fixture.ts | 4 +- tests/helpers/unit-worker-fixture.ts | 2 +- tests/local-checks.test.ts | 2 +- tests/test-invocation-child.test.ts | 49 +++++++++ tests/test-invocation-lifetime.test.ts | 52 +++++++++ tests/test-script.test.ts | 2 +- tests/test-shards.test.ts | 2 +- 12 files changed, 267 insertions(+), 60 deletions(-) create mode 100644 scripts/test-invocation-child.ts create mode 100644 tests/test-invocation-child.test.ts diff --git a/docs/decisions/ci-utility.md b/docs/decisions/ci-utility.md index 4e84a19cf..6dba0b047 100644 --- a/docs/decisions/ci-utility.md +++ b/docs/decisions/ci-utility.md @@ -300,6 +300,18 @@ revokes it; leftover files and the builder's best-effort observation fingerprint have no authority. Failed or signaled preparation stops the invocation before tests; missing diagnostics after successful preparation leave hooks rebuilding. +Build and test commands each run beneath a private process-group anchor that +stays alive until command exit and both output EOFs have been joined. Cancellation +signals only that anchored group, with one-second forced cleanup for survivors +and children forked during signal handling. Losing the wrapper's IPC lifetime +channel also stops the group. A command leader exiting first cannot orphan its +inherited-output holders, and a nested wrapper does not signal the outer lease +or unrelated siblings. The anchor closes its own output descriptors after launch +so it cannot manufacture EOF or hold output open itself. Cancellation after a +successful builder exit still prevents attestation. This is cooperating +process-group ownership, not containment of processes deliberately escaping +their process group; it adds no product extension interface. + Every ordinary nested wrapper creates a fresh invocation. Only the explicitly checked isolated external-speech child borrows its live parent's build, outside both its setup and outer-test deadlines. Direct/unattested hooks rebuild; diff --git a/scripts/test-invocation-child.ts b/scripts/test-invocation-child.ts new file mode 100644 index 000000000..909cfb1bc --- /dev/null +++ b/scripts/test-invocation-child.ts @@ -0,0 +1,131 @@ +/** Private build/test process-group lifetime. No persistent or product API. */ +import { closeSync, readdirSync, readFileSync } from "node:fs"; +import { inheritWorkspaceLease, processIdentity } from "./workspace-lease.mjs"; + +type Usage = { cpuTime: { user: number; system: number }; maxRSS: number } | undefined; +type Message = { kind: "anchor"; start: string } | { kind: "started"; pid: number } | + { kind: "exit"; code: number; signal: NodeJS.Signals | null; usage: Usage } | { kind: "error"; error: string }; + +/** The anchor stays alive after the command exits, until both output EOFs are + * joined. Its IPC channel is also a parent-lifetime pipe: a dead wrapper cannot + * leave this detached group running. Only this live anchor signals its group. + */ +export function invocationChild(command: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; stdin?: "ignore" | "inherit" } = {}) { + let commandPid = 0, signalCode: NodeJS.Signals | null = null, usage: Usage; + let started = false, exited = false, released = false; + let resolveReady!: () => void, rejectReady!: (error: Error) => void, resolveExit!: (code: number) => void; + const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }); + const exit = new Promise(resolve => { resolveExit = resolve; }); + void ready.catch(() => {}); + const anchor = Bun.spawn([process.execPath, import.meta.path, "--anchor", ...command], inheritWorkspaceLease({ + ...options, stdin: options.stdin ?? "ignore", stdout: "pipe", stderr: "pipe", detached: process.platform !== "win32", + ipc(message: Message, child: ReturnType) { + if (message.kind === "error") console.error("Invocation command failed", message.error); + if (message.kind === "anchor") { + // The acknowledged anchor cannot exit normally or launch the command + // before this handshake. Bind its PID/start before granting launch. + if (process.platform === "linux" && processIdentity(child.pid)?.start !== message.start) { child.kill("SIGKILL"); return; } + child.send({ kind: "launch" }); + } else if (message.kind === "started") { + commandPid = message.pid; started = true; resolveReady(); + } else if (message.kind === "exit") { + signalCode = message.signal; usage = message.usage; exited = true; resolveExit(message.code); + } + }, + })); + void anchor.exited.then(code => { + if (!started) rejectReady(new Error("Invocation anchor stopped before command startup")); + if (!exited) { signalCode = anchor.signalCode; resolveExit(code || 1); } + }); + let settling: Promise | undefined; + const settled = () => settling ??= (async () => { + await anchor.exited; + if (process.platform !== "linux") return; + // killpg has delivered SIGKILL, but reaping the anchor alone does not prove + // its last member has stopped yet. Observe the group; never signal a numeric + // group ID after its anchor died (and might eventually be reused). + const liveMembers = () => readdirSync("/proc").some(name => { + if (!/^\d+$/.test(name)) return false; + try { + const stat = readFileSync(`/proc/${name}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + return !["Z", "X"].includes(fields[0]!) && Number(fields[2]) === anchor.pid && Number(fields[3]) === anchor.pid; + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; return false; } + }); + const end = Date.now() + 1_000; + while (liveMembers() && Date.now() < end) await Bun.sleep(10); + if (liveMembers()) throw new Error("Invocation process group cleanup incomplete"); + })(); + let stopping: Promise | undefined; + return { + ready, + get pid() { return commandPid; }, + get signalCode() { return signalCode; }, + get cancelled() { return stopping !== undefined; }, + stdout: anchor.stdout as ReadableStream, stderr: anchor.stderr as ReadableStream, exited: exit, + resourceUsage: () => usage, + stop(signal: NodeJS.Signals): Promise { + if (!stopping) { + if (anchor.exitCode === null && anchor.signalCode === null) anchor.send({ kind: "stop", signal }); + stopping = settled(); + } + return stopping; + }, + async finish() { + if (!released) { + released = true; + if (anchor.exitCode === null && anchor.signalCode === null) anchor.send({ kind: "release" }); + } + await settled(); + }, + }; +} + +if (import.meta.main && process.argv[2] === "--anchor") { + if (!process.send) throw new Error("Invocation anchor requires its private lifetime channel"); + const pid = process.pid; + if (process.platform === "linux") { + const fields = readFileSync("/proc/self/stat", "utf8").split(") ").at(-1)!.split(" "); + if (Number(fields[2]) !== pid || Number(fields[3]) !== pid) throw new Error("Invocation anchor requires its own session and process group"); + } + let stopping = false, launched = false; + let command: ReturnType | undefined; + // The anchor, unlike its command, must survive TERM until group escalation. + const killGroup = (signal: NodeJS.Signals) => { + if (process.platform === "win32") { command?.kill(signal); process.exit(1); } + process.kill(-pid, signal); + }; + const stop = (signal: NodeJS.Signals) => { + if (stopping) return; + stopping = true; + killGroup(signal); + // Includes children forked by a signal handler after the first signal. + setTimeout(() => killGroup("SIGKILL"), 1_000); + }; + const send = (message: Message) => { + try { process.send!(message); } + catch { stop("SIGTERM"); } // The parent may die between exit and reporting. + }; + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, () => stop(signal)); + process.on("disconnect", () => stop("SIGTERM")); + process.on("message", async (message: { kind: string; signal?: NodeJS.Signals }) => { + if (message.kind === "stop") { stop(message.signal ?? "SIGTERM"); return; } + if (message.kind === "release") { killGroup("SIGKILL"); return; } + if (message.kind !== "launch" || launched || stopping) return; + launched = true; + try { + command = Bun.spawn(process.argv.slice(3), inheritWorkspaceLease({ stdin: "inherit", stdout: "inherit", stderr: "inherit" })); + send({ kind: "started", pid: command.pid }); + // Keep only the command's output descriptors alive. The parent can now + // observe actual descendant EOF while this group anchor remains live. + closeSync(1); closeSync(2); + const code = await command.exited; + const resource = command.resourceUsage(); + const usage: Usage = resource ? { cpuTime: { user: Number(resource.cpuTime.user), system: Number(resource.cpuTime.system) }, maxRSS: Number(resource.maxRSS) } : undefined; + send({ kind: "exit", code, signal: command.signalCode, usage }); + } catch (error) { send({ kind: "error", error: String(error) }); stop("SIGTERM"); } + }); + send({ kind: "anchor", start: processIdentity(pid)?.start ?? "" }); + // IPC can be unreferenced by the runtime when no message handler is active. + setInterval(() => {}, 60_000); +} diff --git a/scripts/test-invocation.ts b/scripts/test-invocation.ts index e9e84dd3d..745196458 100644 --- a/scripts/test-invocation.ts +++ b/scripts/test-invocation.ts @@ -1,10 +1,11 @@ /** One fresh build owned by one live test invocation; never cross-run freshness. */ -import { closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, realpathSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { createHash, randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { selectBuildPackages } from "./publishable-packages"; -import { inheritWorkspaceLease, liveAncestor, processIdentity, workspaceLeaseIdentity } from "./workspace-lease.mjs"; +import { liveAncestor, processIdentity, workspaceLeaseIdentity } from "./workspace-lease.mjs"; +import { invocationChild } from "./test-invocation-child"; const AUTHORITY = "BRAIN_TEST_BUILD_AUTHORITY"; const NONCE = "BRAIN_TEST_INVOCATION_NONCE"; @@ -97,13 +98,14 @@ export function clearInvocation(): string { /** Called after write admission. Caller forwards cancellation throughout build/test. */ export async function prepareInvocation(root: string, directories: readonly string[] | undefined, - started: (child: ReturnType) => void): Promise<{ code: number; dispose: () => void }> { + started: (child: ReturnType) => void): Promise<{ code: number; dispose: () => void }> { const nonce = process.env[NONCE]!; const selected = selectBuildPackages(root, directories).map(pkg => pkg.dir); const buildId = randomUUID(), before = performance.now(); - const child = Bun.spawn([process.execPath, join(root, "scripts/build.ts"), ...(directories ? ["--for", directories.join(",")] : [])], - inheritWorkspaceLease({ cwd: root, stdin: "ignore", stdout: "pipe", stderr: "pipe", env: { ...process.env, BRAIN_TEST_BUILD_ID: buildId } })); + const child = invocationChild([process.execPath, join(root, "scripts/build.ts"), ...(directories ? ["--for", directories.join(",")] : [])], + { cwd: root, stdin: "ignore", env: { ...process.env, BRAIN_TEST_BUILD_ID: buildId } }); started(child); + await child.ready; event("build-start", { nonce, builderPid: child.pid, buildId, packages: selected }); async function drain(stream: ReadableStream, channel: "stdout" | "stderr") { let bytes = 0; @@ -112,12 +114,13 @@ export async function prepareInvocation(root: string, directories: readonly stri } const results = await Promise.allSettled([child.exited.then(code => { event("build-exit", { nonce, builderPid: child.pid, code, signal: child.signalCode }); return code; }), drain(child.stdout as ReadableStream, "stdout"), drain(child.stderr as ReadableStream, "stderr")]); + await child.finish(); const resource = child.resourceUsage(); const usage = resource ? { cpuUserUs: Number(resource.cpuTime.user), cpuSystemUs: Number(resource.cpuTime.system), maxRssBytes: Number(resource.maxRSS) } : undefined; const exited = results[0]; const code = exited.status === "fulfilled" ? exited.value : 1; - if (code !== 0 || child.signalCode || results.some(result => result.status === "rejected")) { + if (code !== 0 || child.signalCode || child.cancelled || results.some(result => result.status === "rejected")) { event("preparation-failed", { nonce, builderPid: child.pid, code, usage, elapsedMs: performance.now() - before }); return { code: code || 1, dispose() {} }; } @@ -180,44 +183,3 @@ export async function prepareInvocation(root: string, directories: readonly stri return { code: 0, dispose() {} }; } } - -/** Stop this invocation's actual child tree before draining/releasing ownership. - * Nested wrappers share the enclosing flock/group, so killing only their builder - * cannot rely on the enclosing group exiting. PID/start pairs bound every signal. - */ -export function invocationChild(child: ReturnType) { - const start = processIdentity(child.pid)?.start; - let stopped: Promise | undefined; - return { - stop(signal: NodeJS.Signals): Promise { - if (stopped) return stopped; - stopped = (async () => { - if (process.platform !== "linux" || !start) { child.kill(signal); await child.exited; return; } - if (processIdentity(child.pid)?.start !== start) return; - const processes = readdirSync("/proc").filter(name => /^\d+$/.test(name)).flatMap(name => { - const pid = Number(name), state = processIdentity(pid); - return state ? [{ pid, ...state }] : []; - }); - const owned = [{ pid: child.pid, start }]; - for (let index = 0; index < owned.length; index++) { - for (const process of processes) if (process.parent === owned[index]!.pid && !owned.some(item => item.pid === process.pid)) owned.push(process); - } - const live = (item: { pid: number; start: string }) => processIdentity(item.pid)?.start === item.start; - const signalOwned = (signal: NodeJS.Signals) => { - for (const item of [...owned].reverse()) if (live(item)) { - try { process.kill(item.pid, signal); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } - } - }; - signalOwned(signal); - const grace = Date.now() + 1_000; - while (owned.some(live) && Date.now() < grace) await Bun.sleep(10); - if (owned.some(live)) signalOwned("SIGKILL"); - await child.exited; - const end = Date.now() + 1_000; - while (owned.some(live) && Date.now() < end) await Bun.sleep(10); - if (owned.some(live)) throw new Error("Invocation child cleanup incomplete"); - })(); - return stopped; - }, - }; -} diff --git a/scripts/test.ts b/scripts/test.ts index b7dea3c3a..204efac7f 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -19,9 +19,10 @@ import { constants } from "node:os"; import { join, resolve } from "node:path"; import { realpathSync } from "node:fs"; +import { invocationChild } from "./test-invocation-child"; import { runtimeBuildSelection } from "./runtime-test-builds"; -import { acceptsSpeechDelegation, clearInvocation, invocationChild, prepareInvocation } from "./test-invocation"; -import { ensureWorkspaceLease, inheritWorkspaceLease, workspaceTestAccess } from "./workspace-lease.mjs"; +import { acceptsSpeechDelegation, clearInvocation, prepareInvocation } from "./test-invocation"; +import { ensureWorkspaceLease, workspaceTestAccess } from "./workspace-lease.mjs"; /** What runs when the caller names no path. */ export const DEFAULT_ROOTS: readonly string[] = ["packages", "tests"]; @@ -176,7 +177,7 @@ if (import.meta.main) { let owned: ReturnType | undefined; const stopping: Promise[] = []; const stop = (signal: NodeJS.Signals) => { const pending = owned?.stop(signal); if (pending) { stopping.push(pending); void pending.catch(() => {}); } }; - const started = (child: ReturnType) => { owned = invocationChild(child); if (cancelled) stop(cancelled); }; + const started = (child: ReturnType) => { owned = child; if (cancelled) stop(cancelled); }; const listeners = signals.map(signal => () => { cancelled = signal; stop(signal); }); signals.forEach((signal, index) => process.on(signal, listeners[index]!)); let dispose = () => {}; @@ -193,12 +194,12 @@ if (import.meta.main) { if (preparation.code !== 0) { result = preparation.code; throw new Error(`Test invocation preparation failed (${result})`); } } if (!cancelled) { - const tests = Bun.spawn([process.execPath, "test", "--preload", join(import.meta.dir, "test-network-preload.ts"), ...argv], inheritWorkspaceLease({ - stdin: "inherit", stdout: "pipe", stderr: "pipe", - })); + const tests = invocationChild([process.execPath, "test", "--preload", join(import.meta.dir, "test-network-preload.ts"), ...argv], { stdin: "inherit" }); started(tests); + await tests.ready; const relay = async (stream: ReadableStream, channel: "stdout" | "stderr") => { for await (const chunk of stream) process[channel].write(chunk); }; const joined = await Promise.allSettled([tests.exited, relay(tests.stdout, "stdout"), relay(tests.stderr, "stderr")]); + await tests.finish(); result = joined[0].status === "fulfilled" ? joined[0].value : 1; if (joined.some(item => item.status === "rejected")) result = result || 1; if (tests.signalCode) result = 128 + (constants.signals[tests.signalCode] ?? 0); diff --git a/tests/experiment-tier.test.ts b/tests/experiment-tier.test.ts index caf6cf474..c356c8f65 100644 --- a/tests/experiment-tier.test.ts +++ b/tests/experiment-tier.test.ts @@ -16,7 +16,7 @@ import { } from "../scripts/test-experiments"; const ROOT = join(import.meta.dir, ".."); -const SCRIPTS = ["test.ts", "runtime-test-builds.ts", "test-invocation.ts", "publishable-packages.ts", "test-experiments.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", +const SCRIPTS = ["test.ts", "runtime-test-builds.ts", "test-invocation.ts", "test-invocation-child.ts", "publishable-packages.ts", "test-experiments.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]; const passing = (name: string) => `import { expect, test } from "bun:test";\ntest(${JSON.stringify(name)}, () => expect(1).toBe(1));\n`; diff --git a/tests/helpers/test-invocation-fixture.ts b/tests/helpers/test-invocation-fixture.ts index 26a75ba1e..c988a8478 100644 --- a/tests/helpers/test-invocation-fixture.ts +++ b/tests/helpers/test-invocation-fixture.ts @@ -10,7 +10,7 @@ export function invocationFixture(parent = tmpdir()) { write("bun.lock", "{}\n"); write(".gitignore", "node_modules/\npackages/*/dist/\ntmp/\n"); write("tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2019", module: "ESNext", moduleResolution: "Bundler", types: [], skipLibCheck: true } })); - for (const file of ["build.ts", "build-execution.ts", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs", "test.ts", "test-invocation.ts", "runtime-test-builds.ts", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "unit-worker-entry.sh"]) { + for (const file of ["build.ts", "build-execution.ts", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs", "test.ts", "test-invocation.ts", "test-invocation-child.ts", "runtime-test-builds.ts", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "unit-worker-entry.sh"]) { write(`scripts/${file}`, readFileSync(join(source, "scripts", file), "utf8")); } for (const file of ["packages/core/tests/cli-harness.ts", "packages/ui-react/tests/fixtures/runtime-package-build.ts", "packages/ui-react/tests/fixtures/runtime-hook-phases.ts"]) write(file, readFileSync(join(source, file), "utf8")); @@ -27,7 +27,7 @@ export function invocationFixture(parent = tmpdir()) { import {clearInvocation,prepareInvocation} from "./scripts/test-invocation"; const code=await ensureWorkspaceLease(import.meta.dir,"write");if(code!==undefined)process.exit(code); clearInvocation();let active;let cancelled=false; -for(const signal of ["SIGINT","SIGTERM","SIGHUP"])process.on(signal,()=>{cancelled=true;active?.kill(signal)}); +for(const signal of ["SIGINT","SIGTERM","SIGHUP"])process.on(signal,()=>{cancelled=true;active?.stop ? active.stop(signal) : active?.kill(signal)}); const preparation=await prepareInvocation(import.meta.dir,(process.env.ONLY||"ithaca,troy").split(","),child=>{active=child}); try { if(preparation.code||cancelled)process.exitCode=preparation.code||1;else { active=Bun.spawn([process.execPath,"probe.ts"],inheritWorkspaceLease({stdio:["inherit","inherit","inherit"]}));process.exitCode=await active.exited; diff --git a/tests/helpers/unit-worker-fixture.ts b/tests/helpers/unit-worker-fixture.ts index 9770bccd5..3d7096f56 100644 --- a/tests/helpers/unit-worker-fixture.ts +++ b/tests/helpers/unit-worker-fixture.ts @@ -64,7 +64,7 @@ const shared=${JSON.stringify(join(root,"tmp"))};writeFileSync(join(shared,"buil const stop=Date.now()+5000;while((!existsSync(join(shared,"built-1"))||!existsSync(join(shared,"built-2")))&&Date.now() { + const root = mkdtempSync(join(tmpdir(), "odysseus-anchor-start-")); + const marker = join(root, "launched"); + const child = invocationChild([process.execPath, "-e", `require("fs").writeFileSync(${JSON.stringify(marker)},"launched");`]); + try { + const stopped = child.stop("SIGTERM"); + await expect(child.ready).rejects.toThrow("before command startup"); + await Promise.all([stopped, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(await child.exited).not.toBe(0); + expect(existsSync(marker), "CANCEL_BEFORE_LAUNCH_HAS_NO_COMMAND").toBe(false); + expect(child.cancelled).toBe(true); + } finally { await child.finish(); rmSync(root, { recursive: true, force: true }); } +}); + +test("normal completion preserves command status and stops descendants after both output EOFs", async () => { + const root = mkdtempSync(join(tmpdir(), "odysseus-anchor-finish-")); + const marker = join(root, "descendant"); + const child = invocationChild(["node", "-e", ` +const fs=require("node:fs"), {spawn}=require("node:child_process"); +const c=spawn("node",["-e","setInterval(()=>{},1000)"],{stdio:"ignore"});c.unref(); +fs.writeFileSync(${JSON.stringify(marker)},JSON.stringify({pid:c.pid,start:fs.readFileSync("/proc/"+c.pid+"/stat","utf8").split(") ")[1].split(" ")[19]})); +console.log("Odysseus stdout");console.error("Penelope stderr");process.exitCode=21;`]); + let descendant: { pid: number; start: string } | undefined; + try { + await child.ready; + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + descendant = JSON.parse(readFileSync(marker, "utf8")); + expect(code, "ACTUAL_COMMAND_EXIT_CODE").toBe(21); + expect(stdout).toBe("Odysseus stdout\n"); expect(stderr).toBe("Penelope stderr\n"); + expect(child.resourceUsage()?.cpuTime.user).toBeGreaterThan(0); + expect(processIdentity(descendant!.pid)?.start, "DESCENDANT_OUTLIVES_COMMAND_AND_OUTPUT").toBe(descendant!.start); + await child.finish(); + const end = Date.now() + 1_000; + while (processIdentity(descendant!.pid)?.start === descendant!.start && Date.now() < end) await Bun.sleep(10); + expect(processIdentity(descendant!.pid)?.start, "FINISH_STOPS_OWNED_GROUP").not.toBe(descendant!.start); + expect(child.cancelled).toBe(false); expect(child.signalCode).toBeNull(); expect(await child.exited).toBe(21); + } finally { + await child.stop("SIGTERM"); await child.finish(); + if (descendant && processIdentity(descendant.pid)?.start === descendant.start) process.kill(descendant.pid, "SIGKILL"); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/test-invocation-lifetime.test.ts b/tests/test-invocation-lifetime.test.ts index 6608bec2f..958f89b90 100644 --- a/tests/test-invocation-lifetime.test.ts +++ b/tests/test-invocation-lifetime.test.ts @@ -175,3 +175,55 @@ const child=Bun.spawn([process.execPath,...${JSON.stringify(args)}],{stdout:"inh const result = await running.done; expect(result.code, result.output).toBe(0); expect(result.output).not.toContain("(fail)"); } finally { write("tmp/stop-watch", "stop"); running.child.kill(); await running.done; } }); + +for (const phase of ["build", "tests"] as const) for (const cancellation of ["SIGTERM", "SIGKILL", "late-fork"] as const) +test(`nested ${phase} ${cancellation} stops an exited leader's EOF holder while its outer lease stays live`, async () => { + const { root, write } = fixture(); + // Node unref deliberately lets the producer exit while its descendant retains + // both inherited output descriptors. This is the actual wrapper, not stop alone. + write("late.cjs", `const fs=require("node:fs");fs.writeFileSync("tmp/late",JSON.stringify({pid:process.pid,start:fs.readFileSync("/proc/self/stat","utf8").split(") ")[1].split(" ")[19]}));process.on("SIGTERM",()=>{});setInterval(()=>{},1000);`); + write("hold.cjs", `const fs=require("node:fs");fs.writeFileSync("tmp/orphan",JSON.stringify({pid:process.pid,start:fs.readFileSync("/proc/self/stat","utf8").split(") ")[1].split(" ")[19]}));${cancellation === "late-fork" ? 'process.on("SIGTERM",()=>{require("node:child_process").spawn("node",["late.cjs"],{stdio:["ignore",1,2]}).unref()});' : ""}setInterval(()=>{},1000);`); + const spawnHolder = `const {spawn}=require("node:child_process");const c=spawn("node",["hold.cjs"],{stdio:["ignore",1,2]});c.unref();`; + write("produce.cjs", 'const fs=require("node:fs");if(!fs.existsSync("tmp/producer-started")){fs.writeFileSync("tmp/producer-started","once");' + spawnHolder + "}"); + write("nested.test.ts", phase === "build" ? 'import {test} from "bun:test";test("must not start",()=>console.log("NESTED_TESTS_STARTED"));' : + `import {spawn} from "node:child_process";const c=spawn("node",["produce.cjs"],{stdio:["ignore",1,2]});await new Promise(resolve=>c.once("exit",resolve));console.log("TEST_LEADER_EXIT");process.exit(0);`); + write("probe.ts", consumerProbe + ` +import {existsSync} from "node:fs"; +import {processIdentity,workspaceLeaseIdentity} from "./scripts/workspace-lease.mjs"; +${phase === "build" ? `writeFileSync("node_modules/.bin/tsc",readFileSync("node_modules/.bin/tsc","utf8")+'\\nconst holder=Bun.spawn(["node","produce.cjs"],{stdout:"inherit",stderr:"inherit"});await holder.exited;process.exit(process.exitCode||0);'.replaceAll('\\\\n','\\n'));` : ""} +const lease=workspaceLeaseIdentity(root); +const sibling=Bun.spawn(["node","-e","setInterval(()=>{},1000)"],{stdout:"ignore",stderr:"ignore"}); +const siblingStart=processIdentity(sibling.pid)?.start; +const child=Bun.spawn([process.execPath,"scripts/test.ts",${phase === "build" ? '"--internal-full-preparation",' : ""}"nested.test.ts"],{stdout:"pipe",stderr:"pipe"}); +let output="",done=false;const drain=async stream=>{for await(const bytes of stream)output+=new TextDecoder().decode(bytes)}; +const joined=Promise.all([child.exited,drain(child.stdout),drain(child.stderr)]).then(([code])=>{done=true;return code}); +let orphan; +try { + const ready=Date.now()+10000;while((!existsSync("tmp/orphan")||!output.includes(${JSON.stringify(phase === "build" ? '"event":"build-exit"' : "TEST_LEADER_EXIT")}))&&Date.now() = {}) { if (existsSync(script)) { mkdirSync(join(dir, "scripts")); cpSync(script, join(dir, "scripts/test.ts")); - for (const helper of ["runtime-test-builds.ts", "test-invocation.ts", "publishable-packages.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { + for (const helper of ["runtime-test-builds.ts", "test-invocation.ts", "test-invocation-child.ts", "publishable-packages.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { cpSync(join(ROOT, "scripts", helper), join(dir, "scripts", helper)); } // The actual preload also owns cleanup for the shared CLI harness. diff --git a/tests/test-shards.test.ts b/tests/test-shards.test.ts index 9616dfc1f..02ab31ca6 100644 --- a/tests/test-shards.test.ts +++ b/tests/test-shards.test.ts @@ -59,7 +59,7 @@ describe("balanced shards run the root command for real", () => { mkdirSync(join(dir, "scripts")); const manifest = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "fixture", scripts: { test: manifest.scripts.test } })); - for (const script of ["test.ts", "runtime-test-builds.ts", "test-invocation.ts", "publishable-packages.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { + for (const script of ["test.ts", "runtime-test-builds.ts", "test-invocation.ts", "test-invocation-child.ts", "publishable-packages.ts", "test-shards.ts", "test-shard-costs.json", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "workspace-lease.mjs"]) { cpSync(join(ROOT, "scripts", script), join(dir, "scripts", script)); } mkdirSync(join(dir, "packages/core/tests"), { recursive: true }); From 1ad3d0b76a3c92ca8d8473b7af9a6445a57373c6 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Thu, 22 Oct 2026 11:53:37 +0200 Subject: [PATCH 4/4] fix(test): revoke invocation builds when shared output is rewritten A nested wrapper, explicit build or clean beneath the same live lease could replace dist (even while failing or being cancelled) and the outer invocation's successful receipt still admitted its hooks, so a runtime consumer read output that neither the original nor the restored source produced. Every writer of the shared package output now replaces a random workspace output-generation token before its first mutation; the build receipt records its build's token and admission requires it to be current. The token only revokes and never makes output fresh. Help requests reach Bun without preparing, so a compiler error no longer hides usage. Refs #2279 Co-Authored-By: Claude Opus 5.5 --- docs/decisions/ci-utility.md | 10 ++++ scripts/build-execution.ts | 4 +- scripts/build-receipt.ts | 4 +- scripts/build.ts | 7 ++- scripts/clean.ts | 3 +- scripts/test-invocation.ts | 11 +++-- scripts/test.ts | 6 ++- scripts/workspace-lease.d.mts | 2 + scripts/workspace-lease.mjs | 32 ++++++++++++- tests/helpers/test-invocation-fixture.ts | 2 +- tests/test-invocation.test.ts | 61 +++++++++++++++++++++++- 11 files changed, 126 insertions(+), 16 deletions(-) diff --git a/docs/decisions/ci-utility.md b/docs/decisions/ci-utility.md index ce7a0fb8e..63e2d3519 100644 --- a/docs/decisions/ci-utility.md +++ b/docs/decisions/ci-utility.md @@ -321,6 +321,16 @@ standalone coordinated runtime invocations freshly prepare. Shared read batches, unsupported alternate working directories and watch/hot runs remain unattested. Ordinary explicit `scripts/build.ts` calls still compile every selected package, including freshness/configuration/compiler/output-damage fixture controls. + +A receipt describes its own build, not whatever `dist` holds later. Every +writer of the shared package output (`scripts/build.ts`, including nested +wrappers, hook fallbacks and the CI build operation, and `scripts/clean.ts`) +first replaces the workspace's random output generation token, before any +mutation. The receipt records its build's token, and admission requires it to +be current. A later successful, failed or cancelled writer beneath the same +lease therefore revokes the outer invocation's authority, and its remaining +hooks rebuild. The token only revokes; matching it never makes output fresh. +`--help` and `-h` reach Bun without preparation. Client/browser bundling stays fresh and consumers keep their default exports. Source edits during a run take effect at the next invocation, as explicitly diff --git a/scripts/build-execution.ts b/scripts/build-execution.ts index cea217ace..3c9e00e0e 100644 --- a/scripts/build-execution.ts +++ b/scripts/build-execution.ts @@ -5,8 +5,8 @@ import { buildReceipt } from "./build-receipt"; import { markBinsExecutable } from "./bin-modes"; import type { PublishablePackage } from "./publishable-packages"; -export async function executeBuild(root: string, packages: PublishablePackage[], jobs: 1 | 2, subset = true): Promise { - const receipt = buildReceipt(root, { jobs, subset, packages: packages.map(pkg => pkg.dir) }); +export async function executeBuild(root: string, packages: PublishablePackage[], jobs: 1 | 2, subset = true, generation?: string): Promise { + const receipt = buildReceipt(root, { jobs, subset, generation, packages: packages.map(pkg => pkg.dir) }); const bunx = Bun.which("bunx"); if (!bunx) { console.error( diff --git a/scripts/build-receipt.ts b/scripts/build-receipt.ts index 34ee9c650..4652bf4b5 100644 --- a/scripts/build-receipt.ts +++ b/scripts/build-receipt.ts @@ -8,7 +8,7 @@ type Compiler = "tsc" | "css"; type Child = { compiler: Compiler; pid: number; startedAt: number; elapsedMs?: number; code?: number }; type Package = { name: string; state: "pending" | "running" | "complete" | "failed"; startedAt?: number; elapsedMs?: number; children: Child[] }; -export function buildReceipt(root: string, options: { jobs: number; subset: boolean; packages: readonly string[] }) { +export function buildReceipt(root: string, options: { jobs: number; subset: boolean; generation?: string; packages: readonly string[] }) { let available = true; function unavailable() { if (!available) return; @@ -22,7 +22,7 @@ export function buildReceipt(root: string, options: { jobs: number; subset: bool const path = join(directory, `build-${process.pid}-${id}-probe.json`); const started = performance.now(); const packages: Package[] = options.packages.map(name => ({ name, state: "pending", children: [] })); - const receipt = { schemaVersion: 1, kind: "workspace-build", id, pid: process.pid, startedAt: Date.now(), + const receipt = { schemaVersion: 1, kind: "workspace-build", id, pid: process.pid, generation: options.generation, startedAt: Date.now(), mode: options.subset ? "subset" : "full", jobs: options.jobs, state: "running" as "running" | "complete", elapsedMs: 0, success: false, code: null as number | null, packages }; const elapsed = () => performance.now() - started; diff --git a/scripts/build.ts b/scripts/build.ts index 13c966292..755980772 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,7 +1,7 @@ import { resolve } from "path"; import { executeBuild } from "./build-execution"; import { selectBuildPackages } from "./publishable-packages.ts"; -import { ensureWorkspaceLease } from "./workspace-lease.mjs"; +import { advanceOutputGeneration, ensureWorkspaceLease } from "./workspace-lease.mjs"; // Build order is not load-bearing: each package's tsconfig.build.json extends // the root tsconfig, whose customConditions ["bun"] resolve cross-package @@ -54,6 +54,9 @@ if (dryRun) { const coordinated = await ensureWorkspaceLease(root, "write"); if (coordinated !== undefined) process.exit(coordinated); +// Revoke any live invocation's build before the first output mutation, so a +// failed or cancelled build here cannot leave an outer receipt describing dist. +const generation = advanceOutputGeneration(root); // Intent requests this one operation; its independent qualification and actual // captured input/output checks supply authority. Unqualified contexts rebuild. if (process.env.BRAIN_CI_BUILD_OPERATION === "isolated-v1") { @@ -61,4 +64,4 @@ if (process.env.BRAIN_CI_BUILD_OPERATION === "isolated-v1") { const code = await attemptCiBuild(root, { directories, jobs: jobs as 1 | 2, force }); if (code !== undefined) process.exit(code); } -process.exitCode = await executeBuild(root, packages, jobs as 1 | 2, directories !== undefined); +process.exitCode = await executeBuild(root, packages, jobs as 1 | 2, directories !== undefined, generation); diff --git a/scripts/clean.ts b/scripts/clean.ts index 47e24aec7..3097d496c 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -1,10 +1,11 @@ import { rmSync } from "fs"; import { resolve } from "path"; import { listPublishablePackages } from "./publishable-packages.ts"; -import { ensureWorkspaceLease } from "./workspace-lease.mjs"; +import { advanceOutputGeneration, ensureWorkspaceLease } from "./workspace-lease.mjs"; const coordinated = await ensureWorkspaceLease(resolve(import.meta.dir, ".."), "write"); if (coordinated !== undefined) process.exit(coordinated); +advanceOutputGeneration(resolve(import.meta.dir, "..")); // The same manifest-derived list the build writes dist/ for. const packages = listPublishablePackages(resolve(import.meta.dir, "..")).map((pkg) => pkg.dir); diff --git a/scripts/test-invocation.ts b/scripts/test-invocation.ts index 745196458..48eecddb0 100644 --- a/scripts/test-invocation.ts +++ b/scripts/test-invocation.ts @@ -4,7 +4,7 @@ import { createHash, randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { selectBuildPackages } from "./publishable-packages"; -import { liveAncestor, processIdentity, workspaceLeaseIdentity } from "./workspace-lease.mjs"; +import { liveAncestor, outputGeneration, processIdentity, workspaceLeaseIdentity } from "./workspace-lease.mjs"; import { invocationChild } from "./test-invocation-child"; const AUTHORITY = "BRAIN_TEST_BUILD_AUTHORITY"; @@ -18,7 +18,7 @@ const identity = (root: string) => { const path = realpathSync(root), st = statS const event = (event: string, data: Record = {}) => console.error(JSON.stringify({ kind: "test-invocation", event, pid: process.pid, at: Date.now(), ...data })); type Authority = { version: 1; nonce: string; ownerPid: number; ownerStart: string; fd: number; dev: number; ino: number; sha256: string }; -type CompletedBuild = { schemaVersion: number; kind: string; pid: number; id: string; state: string; success: boolean; code: number; jobs: number; +type CompletedBuild = { schemaVersion: number; kind: string; pid: number; id: string; generation: string; state: string; success: boolean; code: number; jobs: number; packages: { name: string; state: string; children: { compiler: string; pid: number; code: number; elapsedMs: number }[] }[] }; type InvocationRecord = { version: 1; nonce: string; ownerPid: number; ownerStart: string; root: ReturnType; lease: NonNullable>; build: CompletedBuild }; @@ -35,7 +35,8 @@ function readRegular(path: string): { bytes: Buffer; dev: number; ino: number } function completedBuild(root: string, build: CompletedBuild, pid: number, id: string, expected?: readonly string[]): boolean { if (!build || build.schemaVersion !== 1 || build.kind !== "workspace-build" || build.pid !== pid || build.id !== id || - !positive(pid) || !uuid.test(id) || build.state !== "complete" || build.success !== true || build.code !== 0 || build.jobs !== 1 || + !positive(pid) || !uuid.test(id) || typeof build.generation !== "string" || !uuid.test(build.generation) || + build.generation !== outputGeneration(root) || build.state !== "complete" || build.success !== true || build.code !== 0 || build.jobs !== 1 || !Array.isArray(build.packages) || !build.packages.length || build.packages.length > 128) return false; const names = build.packages.map(pkg => pkg?.name); if (names.some(name => typeof name !== "string") || new Set(names).size !== names.length || @@ -69,8 +70,10 @@ export function invocationBuild(root: string, directories: readonly string[]): I const built = new Set(record.build.packages.map(pkg => pkg.name)); if (!selectBuildPackages(root, directories).every(pkg => built.has(pkg.dir))) return; const after = statSync(path); + // Any later writer of the shared output, even a failed nested one, has + // advanced the generation and so revoked this receipt. if (after.dev !== st.dev || after.ino !== st.ino || after.size !== st.size || after.ctimeMs !== st.ctimeMs || - !liveAncestor(locator.ownerPid, locator.ownerStart)) return; + outputGeneration(root) !== record.build.generation || !liveAncestor(locator.ownerPid, locator.ownerStart)) return; return record; } catch { /* Missing, stale or malformed authority always rebuilds in the hook. */ } } diff --git a/scripts/test.ts b/scripts/test.ts index 204efac7f..f0613158f 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -130,7 +130,7 @@ if (import.meta.main) { if (coordinated !== undefined) process.exit(coordinated); let args = process.argv.slice(2); let argv: string[]; - let fullPreparation = false, delegatedSpeech = false, unsupported = realpathSync(process.cwd()) !== realpathSync(resolve(import.meta.dir, "..")); + let fullPreparation = false, delegatedSpeech = false, help = false, unsupported = realpathSync(process.cwd()) !== realpathSync(resolve(import.meta.dir, "..")); try { const balanced: string[] = []; const forwarded: string[] = []; @@ -142,6 +142,8 @@ if (import.meta.main) { } if (arg === "--internal-full-preparation") { fullPreparation = true; continue; } if (arg === "--internal-external-speech") { delegatedSpeech = true; continue; } + // Bun prints usage and runs nothing, so help never builds or fails on a compiler error. + if (arg === "--help" || arg === "-h") help = true; if (["--watch", "--hot", "--cwd"].some(flag => arg === flag || arg.startsWith(`${flag}=`))) unsupported = true; if (arg === "--balanced-shard") throw new Error("Expected --balanced-shard=M/N"); if (arg.startsWith("--balanced-shard=")) { @@ -188,7 +190,7 @@ if (import.meta.main) { if (!borrowed) clearInvocation(); if (fullPreparation && workspaceTestAccess() !== "write") throw new Error("Read-only verification cannot prepare workspace output"); const directories = runtimeBuildSelection(root, process.cwd(), paths); - if (!borrowed && !unsupported && workspaceTestAccess() === "write" && (fullPreparation || directories.length)) { + if (!help && !borrowed && !unsupported && workspaceTestAccess() === "write" && (fullPreparation || directories.length)) { const preparation = await prepareInvocation(root, fullPreparation ? undefined : directories, started); dispose = preparation.dispose; if (preparation.code !== 0) { result = preparation.code; throw new Error(`Test invocation preparation failed (${result})`); } diff --git a/scripts/workspace-lease.d.mts b/scripts/workspace-lease.d.mts index 32d81759a..3c31ab3d0 100644 --- a/scripts/workspace-lease.d.mts +++ b/scripts/workspace-lease.d.mts @@ -15,3 +15,5 @@ export interface WorkspaceLeaseIdentity { ownerStart: string; } export function workspaceLeaseIdentity(root: string): WorkspaceLeaseIdentity | undefined; +export function advanceOutputGeneration(root: string): string; +export function outputGeneration(root: string): string | undefined; diff --git a/scripts/workspace-lease.mjs b/scripts/workspace-lease.mjs index 43c32e71a..ce8cc4d62 100644 --- a/scripts/workspace-lease.mjs +++ b/scripts/workspace-lease.mjs @@ -1,5 +1,6 @@ /** Coordinate development commands which share mutable workspace output. */ -import { fstatSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { closeSync, constants as fsConstants, fstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; import { constants } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -83,6 +84,35 @@ export function workspaceLeaseIdentity(root) { } catch { /* Unsupported/missing/replaced ownership cannot attest a build. */ } } +const generationPath = root => join(root, "tmp", "workspace-operation", "output.generation"); +const generationShape = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/; + +/** + * Revoke every earlier claim on the shared package output before mutating it. + * Each writer replaces the token first, so a failed, cancelled or nested write + * still invalidates an outer invocation's attested build. This is revocation + * only: a matching token never makes a build fresh by itself. + */ +export function advanceOutputGeneration(root) { + const path = generationPath(resolve(root)), generation = randomUUID(), staged = `${path}.${process.pid}.${generation}`; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(staged, generation, { flag: "wx", mode: 0o600 }); + renameSync(staged, path); + return generation; +} + +export function outputGeneration(root) { + try { + const fd = openSync(generationPath(resolve(root)), fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK); + try { + const st = fstatSync(fd); + if (!st.isFile() || st.size !== 36) return; + const value = readFileSync(fd, "utf8"); + return generationShape.test(value) ? value : undefined; + } finally { closeSync(fd); } + } catch { /* A missing or replaced token matches no attested build. */ } +} + function sameFile(fd, path) { try { const held = fstatSync(fd), wanted = statSync(path); diff --git a/tests/helpers/test-invocation-fixture.ts b/tests/helpers/test-invocation-fixture.ts index c988a8478..ac63308c7 100644 --- a/tests/helpers/test-invocation-fixture.ts +++ b/tests/helpers/test-invocation-fixture.ts @@ -10,7 +10,7 @@ export function invocationFixture(parent = tmpdir()) { write("bun.lock", "{}\n"); write(".gitignore", "node_modules/\npackages/*/dist/\ntmp/\n"); write("tsconfig.json", JSON.stringify({ compilerOptions: { target: "ES2019", module: "ESNext", moduleResolution: "Bundler", types: [], skipLibCheck: true } })); - for (const file of ["build.ts", "build-execution.ts", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs", "test.ts", "test-invocation.ts", "test-invocation-child.ts", "runtime-test-builds.ts", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "unit-worker-entry.sh"]) { + for (const file of ["build.ts", "clean.ts", "build-execution.ts", "build-receipt.ts", "build-repeat-observation.ts", "bin-modes.ts", "publishable-packages.ts", "workspace-lease.mjs", "test.ts", "test-invocation.ts", "test-invocation-child.ts", "runtime-test-builds.ts", "test-network-preload.ts", "test-network-guard.ts", "test-network-child-preload.ts", "unit-worker-entry.sh"]) { write(`scripts/${file}`, readFileSync(join(source, "scripts", file), "utf8")); } for (const file of ["packages/core/tests/cli-harness.ts", "packages/ui-react/tests/fixtures/runtime-package-build.ts", "packages/ui-react/tests/fixtures/runtime-hook-phases.ts"]) write(file, readFileSync(join(source, file), "utf8")); diff --git a/tests/test-invocation.test.ts b/tests/test-invocation.test.ts index e381090c1..bbf52fde3 100644 --- a/tests/test-invocation.test.ts +++ b/tests/test-invocation.test.ts @@ -118,10 +118,69 @@ test("read-mode lease cannot reuse authority or delete existing output", async ( test("ordinary explicit builders compile even with valid invocation authority", async () => { const { root, write } = fixture(); - write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeDefined();source(2);const child=Bun.spawn([process.execPath,"scripts/build.ts","--for","ithaca"],{stdout:"inherit",stderr:"inherit"});expect(await child.exited).toBe(0);expect(consume(),"EXPLICIT_BUILD_STILL_COMPILES").toBe(2);`); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeDefined();source(2);const child=Bun.spawn([process.execPath,"scripts/build.ts","--for","ithaca"],{stdout:"inherit",stderr:"inherit"});expect(await child.exited).toBe(0);expect(consume(),"EXPLICIT_BUILD_STILL_COMPILES").toBe(2); +source(1);await hook();expect(consume(),"EXPLICIT_BUILD_OUTPUT_NEVER_ATTESTED").toBe(1);expect(invocationBuild(root,["ithaca"]),"EXPLICIT_BUILD_REVOKES_OUTER_AUTHORITY").toBeUndefined();`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(3); +}); + +// The outer receipt describes its own successful build, never whatever a later +// writer beneath the same lease left in dist. Both the original and the +// restored source say 1; only the nested writer ever produced 2. +for (const [name, nested] of Object.entries({ + successful: { edit: 'source(2);', exit: '.toBe(0)' }, + failed: { edit: 'writeFileSync(join(root,"packages/ithaca/src/index.ts"),"export const cargo: string = 2;\\n");', exit: '.not.toBe(0)' }, +})) test(`a ${name} nested wrapper build revokes the outer invocation's authority`, async () => { + const { root, write } = fixture(); + write("nested.test.ts", 'import {test} from "bun:test";test("nested",()=>{});'); + write("probe.ts", consumerProbe + ` +const before=invocationBuild(root,["ithaca"]);expect(before).toBeDefined();expect(consume()).toBe(1); +${nested.edit} +const child=Bun.spawn([process.execPath,"run","test","--internal-full-preparation","nested.test.ts"],{stdout:"inherit",stderr:"inherit"}); +expect(await child.exited,"NESTED_WRAPPER_EXIT")${nested.exit}; +expect(consume(),"NESTED_WRITER_REPLACED_OUTPUT").toBe(2);source(1); +await hook();expect(consume(),"NESTED_WRITER_OUTPUT_NEVER_SERVED").toBe(1); +expect(invocationBuild(root,["ithaca"]),"NESTED_WRITER_REVOKES_OUTER_AUTHORITY").toBeUndefined();`); + const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(3); + expect(result.output, "OUTER_HOOK_NEVER_ATTESTS_REPLACED_OUTPUT").not.toContain('"disposition":"attested"'); +}); + +test("a cancelled nested wrapper build revokes the outer invocation's authority", async () => { + const { root, write } = fixture(); + write("nested.test.ts", 'import {test} from "bun:test";test("must not start",()=>console.log("NESTED_TESTS_STARTED"));'); + write("probe.ts", consumerProbe + ` +expect(invocationBuild(root,["ithaca"])).toBeDefined();expect(consume()).toBe(1); +const compiler=readFileSync("node_modules/.bin/tsc","utf8"); +writeFileSync("node_modules/.bin/tsc",'#!'+process.execPath+'\\nimport {mkdirSync,writeFileSync} from "node:fs";mkdirSync("packages/ithaca/dist",{recursive:true});writeFileSync("packages/ithaca/dist/index.js","export const cargo = 2;\\\\n");writeFileSync("tmp/nested-partial","written");await Bun.sleep(30000);'); +const child=Bun.spawn([process.execPath,"run","test","--internal-full-preparation","nested.test.ts"],{stdout:"pipe",stderr:"pipe"}); +const output=Promise.all([new Response(child.stdout).text(),new Response(child.stderr).text()]); +const end=Date.now()+10000;while(!require("fs").existsSync("tmp/nested-partial")&&Date.now() { + const { root, write } = fixture(); + write("probe.ts", consumerProbe + `expect(invocationBuild(root,["ithaca"])).toBeDefined(); +const child=Bun.spawn([process.execPath,"scripts/clean.ts"],{stdout:"inherit",stderr:"inherit"});expect(await child.exited).toBe(0); +expect(require("fs").existsSync(join(root,"packages/ithaca/dist")),"CLEAN_REMOVED_OUTPUT").toBe(false); +await hook();expect(consume(),"CLEANED_OUTPUT_REBUILT").toBe(1);expect(invocationBuild(root,["ithaca"]),"CLEAN_REVOKES_OUTER_AUTHORITY").toBeUndefined();`); const result = await run(root); expect(result.code, result.output).toBe(0); expect(receipts(root)).toHaveLength(2); }); +for (const flag of ["--help", "-h"]) test(`${flag} prints usage without preparing even when the build would fail`, async () => { + const { root, write } = fixture(); + write("packages/ithaca/src/index.ts", "export const cargo: string = 1;\n"); + const failing = await run(root, ["run", "test", "--internal-full-preparation", "missing.test.ts"]); + expect(failing.code, "FAILING_BUILDER_CONTROL").not.toBe(0); + const help = await run(root, ["run", "test", "--internal-full-preparation", flag]); + expect(help.code, help.output).toBe(0); expect(help.output, "HELP_REACHES_BUN").toContain("Usage: bun test"); + expect(help.output, "HELP_NEVER_PREPARES").not.toContain('"event":"build-start"'); expect(receipts(root), "HELP_NEVER_BUILDS").toHaveLength(1); +}); + test("fresh standalone invocations consume effective config alias and installed compiler changes", async () => { const { root, write } = fixture(); const { renameSync, symlinkSync, writeFileSync } = await import("node:fs");