From 6761ca5a6fcc24bbe3880520dfc512654aaaa962 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 4 Oct 2026 17:25:57 +0900 Subject: [PATCH 1/8] fix(server): wait for a complete Bun before restarting onto a replaced package tree - The package-tree fence treats a replacement whose process.execPath still fails REAL_BUN_MIN_BYTES like an unreadable tree: no restart, then a fresh full debounce once the runtime is ready. An in-place npm install writes package.json and the bun placeholder before its postinstall, so the restart spawned the placeholder (EUNKNOWN on Windows) and exited without a replacement --- src/lib/package-tree-integrity.ts | 21 +++++- .../package-tree-integrity.test.ts | 73 ++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts index 2997e63e9ec..c3c5f5c7e56 100644 --- a/src/lib/package-tree-integrity.ts +++ b/src/lib/package-tree-integrity.ts @@ -1,6 +1,7 @@ import { readFileSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { isRealBunBinary } from "./bun-binary-validator.mjs"; import { isStandaloneBinary } from "./standalone"; export interface PackageTreeObservation { @@ -56,6 +57,14 @@ export interface PackageTreeIntegrityOptions { schedule?: (callback: () => void, delayMs: number) => (() => void) | void; /** Test seam for `installedVersion()`; production reads the package manifest. */ readInstalledVersion?: () => string | undefined; + /** + * Whether the runtime a restart would spawn is complete. An in-place npm install writes + * package.json and extracts the `bun` package's small placeholder before its postinstall + * swaps the real binary in, and the replacement spawns `process.execPath`, that same path. + * While this answers false the replacement is treated like an unreadable tree: no restart, + * then a fresh full debounce once the runtime is ready. + */ + runtimeReady?: () => boolean; } export type ObservePackageTree = () => PackageTreeObservation | null; @@ -195,6 +204,12 @@ export function createPackageTreeIntegrityGuard( replacementCandidate = null; return; } + if (options.runtimeReady && !options.runtimeReady()) { + resetRestartTimer(); + waitingForReadableTree = true; + armRestartTimer(PACKAGE_TREE_RECHECK_MS); + return; + } if (waitingForReadableTree) { waitingForReadableTree = false; replacementCandidate = current; @@ -292,5 +307,9 @@ export function createRuntimePackageTreeIntegrityGuard( if (installer === "source" || isStandaloneBinary()) { return { status: () => ({ ok: true }), dispose: () => {} }; } - return createPackageTreeIntegrityGuard(observe, now, options); + // The restart replacement spawns process.execPath, the bundled Bun in an npm install. + return createPackageTreeIntegrityGuard(observe, now, { + runtimeReady: () => isRealBunBinary(process.execPath), + ...options, + }); } diff --git a/tests/ci-workflows/package-tree-integrity.test.ts b/tests/ci-workflows/package-tree-integrity.test.ts index bf5db8e6f58..f32513545a6 100644 --- a/tests/ci-workflows/package-tree-integrity.test.ts +++ b/tests/ci-workflows/package-tree-integrity.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { chmodSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; import { @@ -254,6 +254,77 @@ describe("package tree integrity", () => { expect(calls).toBe(1); }); + test("waits for the runtime a restart would spawn, then debounces afresh", async () => { + let observation: PackageTreeObservation | null = base; + let clock = 0; + let calls = 0; + let runtimeReady = false; + const scheduler = createScheduler(); + const guard = createPackageTreeIntegrityGuard( + () => observation, + () => clock, + { + onReplaced: () => { calls += 1; }, + replacedRestartDelayMs: 5_000, + schedule: scheduler.schedule, + runtimeReady: () => runtimeReady, + }, + ); + + expect(guard.status()).toEqual({ ok: true }); + // npm wrote the new package.json; bun's postinstall has not replaced its placeholder yet. + observation = { ...base, inode: 11n, contentTimeNs: 200n }; + clock += 2_000; + expect(guard.status()).toEqual({ ok: false, reason: "package_tree_replaced" }); + await scheduler.runNext(); + await scheduler.runNext(); + expect(calls).toBe(0); + expect(scheduler.pending).toHaveLength(1); + + runtimeReady = true; + await scheduler.runNext(); + expect(calls).toBe(0); + await scheduler.runNext(); + expect(calls).toBe(1); + }); + + test("the installed guard reads process.execPath as the runtime", async () => { + // A child Bun runs from a disposable copy, then that path is swapped for a placeholder the + // way an in-place npm install leaves node_modules/bun/bin/bun.exe before its postinstall. + const dir = join(TEST_DIR, "runtime-placeholder"); + mkdirSync(dir, { recursive: true }); + const runtime = join(dir, process.platform === "win32" ? "bun.exe" : "bun"); + copyFileSync(process.execPath, runtime); + chmodSync(runtime, 0o755); + const script = join(dir, "probe.ts"); + const guardModule = join(import.meta.dir, "../../src/lib/package-tree-integrity.ts").replaceAll("\\", "/"); + writeFileSync(script, ` +import { renameSync, writeFileSync } from "node:fs"; +import { createRuntimePackageTreeIntegrityGuard } from ${JSON.stringify(guardModule)}; +const base = { device: 1n, inode: 10n, contentTimeNs: 100n, size: 500n }; +let observation = base; +const pending = []; +let calls = 0; +let clock = 0; +const guard = createRuntimePackageTreeIntegrityGuard("npm", () => observation, () => clock, { + onReplaced: () => { calls += 1; }, + replacedRestartDelayMs: 5_000, + schedule: callback => { pending.push(callback); }, +}); +guard.status(); +renameSync(process.execPath, process.execPath + ".old"); +writeFileSync(process.execPath, "placeholder replaced by the bun package postinstall\\n"); +observation = { ...base, inode: 11n }; +clock += 2_000; +if (guard.status().ok) throw new Error("replacement not detected"); +for (let k = 0; k < 3 && pending.length; k += 1) { pending.shift()(); await Promise.resolve(); await Promise.resolve(); } +console.log(JSON.stringify({ calls })); +`); + const child = Bun.spawnSync([runtime, script], { stdout: "pipe", stderr: "pipe" }); + expect(child.exitCode).toBe(0); + expect(JSON.parse(child.stdout.toString().trim())).toEqual({ calls: 0 }); + }); + test("retries when restart acceptance throws", async () => { let observation: PackageTreeObservation | null = base; let clock = 0; From 9fd8c8afa77d51c3ba462d6b1933bb2c6dc936ca Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 4 Oct 2026 17:25:58 +0900 Subject: [PATCH 2/8] docs(structure): record the fence's wait for the runtime it respawns - The package-tree integrity fence section names the process.execPath gate beside the unreadable-manifest wait --- structure/ops/docs-and-release.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index ff43997aa44..79a0c7d0126 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -366,7 +366,7 @@ observed at startup. Replacing that manifest under a live process fences `/healt stability timer; if the same new manifest identity remains readable and distinct, the timer enters the existing drain-and-restart handoff without waiting for another request. A temporarily unreadable manifest is polled until readable and then receives a fresh full stability interval, while a return to the -startup identity cancels the pending restart. Failed restart admission retries after the same +startup identity cancels the pending restart. The replacement spawns `process.execPath`, which an in-place npm install leaves as the `bun` package's small placeholder until its postinstall runs, so while that path fails the `REAL_BUN_MIN_BYTES` gate the restart waits the same way and then debounces afresh. Failed restart admission retries after the same bounded delay. Stopping the server before the accepted restart begins vetoes it, and a service child restarts only while it still owns the service home. Source checkouts and standalone binaries remain outside this fence. From 8a0745425a585076f519ae7c54bd6cef305d1312 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 4 Oct 2026 17:30:20 +0900 Subject: [PATCH 3/8] fix(server): pin the runtime path at boot and say when the restart waits for it - createRuntimePackageTreeIntegrityGuard reads process.execPath once at construction, since Linux Bun resolves it lazily and reports a swapped-out binary as (deleted); an explicit runtimeReady: undefined no longer disables the gate - The fence logs once when it starts waiting for the runtime, so a postinstall that never runs is visible --- src/lib/package-tree-integrity.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts index c3c5f5c7e56..fda911d79a7 100644 --- a/src/lib/package-tree-integrity.ts +++ b/src/lib/package-tree-integrity.ts @@ -163,6 +163,8 @@ export function createPackageTreeIntegrityGuard( let timerScheduled = false; let cancelScheduled: (() => void) | null = null; let waitingForReadableTree = false; + /** Logged once per wait, so a postinstall that never runs is visible rather than silent. */ + let waitingForRuntime = false; let replacementCandidate: PackageTreeObservation | null = null; /** The replacement identity that survived a full stability interval (see installedVersion). */ let settledReplacement: PackageTreeObservation | null = null; @@ -205,11 +207,16 @@ export function createPackageTreeIntegrityGuard( return; } if (options.runtimeReady && !options.runtimeReady()) { + if (!waitingForRuntime) { + waitingForRuntime = true; + console.warn("Package tree replaced; waiting for its Bun runtime to finish installing before restarting"); + } resetRestartTimer(); waitingForReadableTree = true; armRestartTimer(PACKAGE_TREE_RECHECK_MS); return; } + waitingForRuntime = false; if (waitingForReadableTree) { waitingForReadableTree = false; replacementCandidate = current; @@ -307,9 +314,12 @@ export function createRuntimePackageTreeIntegrityGuard( if (installer === "source" || isStandaloneBinary()) { return { status: () => ({ ok: true }), dispose: () => {} }; } - // The restart replacement spawns process.execPath, the bundled Bun in an npm install. + // The restart replacement spawns process.execPath, the bundled Bun in an npm install. Read it + // now: Linux Bun resolves it lazily, and after npm swaps the package directory a first read + // reports the retired binary as "(deleted)"; reading it once pins the boot path. + const runtimePath = process.execPath; return createPackageTreeIntegrityGuard(observe, now, { - runtimeReady: () => isRealBunBinary(process.execPath), ...options, + runtimeReady: options.runtimeReady ?? (() => isRealBunBinary(runtimePath)), }); } From 526958751e7b2e574565885ce9b2fc514477da94 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 4 Oct 2026 17:44:29 +0900 Subject: [PATCH 4/8] docs(server): describe the package-tree integrity guard - createPackageTreeIntegrityGuard names what it fences and when it hands a replacement to onReplaced, answering the CodeRabbit docstring check --- src/lib/package-tree-integrity.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts index fda911d79a7..b99a424463a 100644 --- a/src/lib/package-tree-integrity.ts +++ b/src/lib/package-tree-integrity.ts @@ -151,6 +151,10 @@ export function sameObservation(left: PackageTreeObservation, right: PackageTree */ const PACKAGE_TREE_RECHECK_MS = 1_000; +/** + * Fences a live process whose package manifest was replaced, and once the replacement is readable, + * stable for the debounce interval and its runtime is ready, hands it to `onReplaced` exactly once. + */ export function createPackageTreeIntegrityGuard( observe: ObservePackageTree = observePackageManifest, now: () => number = Date.now, From 601a2e3927445bc9e12781ec85c0be5af5fe9d48 Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 4 Oct 2026 18:04:23 +0900 Subject: [PATCH 5/8] docs(server): qualify the guard's readiness guarantee - The docstring says onReplaced waits on a configured runtimeReady check, since a direct caller may omit it; the installed-package guard always configures one --- src/lib/package-tree-integrity.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts index b99a424463a..f7211001456 100644 --- a/src/lib/package-tree-integrity.ts +++ b/src/lib/package-tree-integrity.ts @@ -153,7 +153,8 @@ const PACKAGE_TREE_RECHECK_MS = 1_000; /** * Fences a live process whose package manifest was replaced, and once the replacement is readable, - * stable for the debounce interval and its runtime is ready, hands it to `onReplaced` exactly once. + * stable for the debounce interval and any configured `runtimeReady` check passes, hands it to + * `onReplaced` exactly once. */ export function createPackageTreeIntegrityGuard( observe: ObservePackageTree = observePackageManifest, From 8b7ca1261f82059beb3b873f7c0b98f891b3414c Mon Sep 17 00:00:00 2001 From: LilMGenius Date: Sun, 4 Oct 2026 18:14:27 +0900 Subject: [PATCH 6/8] docs(server): say the guard retries onReplaced after a throw - The docstring no longer promises a single call: a throwing restart admission is rechecked and retried after another debounce while the replacement persists, as the existing retry test asserts --- src/lib/package-tree-integrity.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts index f7211001456..5a479efcda7 100644 --- a/src/lib/package-tree-integrity.ts +++ b/src/lib/package-tree-integrity.ts @@ -154,7 +154,8 @@ const PACKAGE_TREE_RECHECK_MS = 1_000; /** * Fences a live process whose package manifest was replaced, and once the replacement is readable, * stable for the debounce interval and any configured `runtimeReady` check passes, hands it to - * `onReplaced` exactly once. + * `onReplaced`. If the callback throws, the guard rechecks the tree and may call it again after + * another debounce interval while the replacement persists. */ export function createPackageTreeIntegrityGuard( observe: ObservePackageTree = observePackageManifest, From 52d568a6e58efcb9ece6c376d4b5d3cfce121018 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 23:30:58 +0900 Subject: [PATCH 7/8] fix(server): withdraw a settled replacement while its Bun runtime is incomplete A replacement that settled before a failed restart admission kept reporting installedVersion after the bun placeholder reappeared, so a manual restart could pass the fenced readiness check before the runtime was complete. Clear the settlement on entering the runtime wait, report no installed version while the runtime is not ready, and reset the once-per-wait warning on baseline recovery. --- src/lib/package-tree-integrity.ts | 6 +++ structure/ops/docs-and-release.md | 2 +- .../package-tree-integrity.test.ts | 45 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts index 5a479efcda7..2b63bffadb5 100644 --- a/src/lib/package-tree-integrity.ts +++ b/src/lib/package-tree-integrity.ts @@ -209,6 +209,7 @@ export function createPackageTreeIntegrityGuard( if (sameObservation(boot, current)) { resetRestartTimer(); waitingForReadableTree = false; + waitingForRuntime = false; replacementCandidate = null; return; } @@ -217,6 +218,9 @@ export function createPackageTreeIntegrityGuard( waitingForRuntime = true; console.warn("Package tree replaced; waiting for its Bun runtime to finish installing before restarting"); } + // A replacement settled before a failed admission is no longer restart-ready: withdraw + // it so installedVersion() stays silent until the fresh debounce settles it again. + settledReplacement = null; resetRestartTimer(); waitingForReadableTree = true; armRestartTimer(PACKAGE_TREE_RECHECK_MS); @@ -267,6 +271,7 @@ export function createPackageTreeIntegrityGuard( return { installedVersion: () => { if (settledReplacement === null) return undefined; + if (options.runtimeReady && !options.runtimeReady()) return undefined; const current = observe(); if (current === null || !sameObservation(settledReplacement, current)) return undefined; return readInstalledVersion(); @@ -299,6 +304,7 @@ export function createPackageTreeIntegrityGuard( lastOkAt = at; resetRestartTimer(); waitingForReadableTree = false; + waitingForRuntime = false; replacementCandidate = null; return { ok: true }; }, diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 79a0c7d0126..e41fa39b3fb 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -366,7 +366,7 @@ observed at startup. Replacing that manifest under a live process fences `/healt stability timer; if the same new manifest identity remains readable and distinct, the timer enters the existing drain-and-restart handoff without waiting for another request. A temporarily unreadable manifest is polled until readable and then receives a fresh full stability interval, while a return to the -startup identity cancels the pending restart. The replacement spawns `process.execPath`, which an in-place npm install leaves as the `bun` package's small placeholder until its postinstall runs, so while that path fails the `REAL_BUN_MIN_BYTES` gate the restart waits the same way and then debounces afresh. Failed restart admission retries after the same +startup identity cancels the pending restart. The replacement spawns `process.execPath`, which an in-place npm install leaves as the `bun` package's small placeholder until its postinstall runs, so while that path fails the `REAL_BUN_MIN_BYTES` gate the restart waits the same way and then debounces afresh; meanwhile `installedVersion` stays unreported, so a manual restart is refused as unsettled. Failed restart admission retries after the same bounded delay. Stopping the server before the accepted restart begins vetoes it, and a service child restarts only while it still owns the service home. Source checkouts and standalone binaries remain outside this fence. diff --git a/tests/ci-workflows/package-tree-integrity.test.ts b/tests/ci-workflows/package-tree-integrity.test.ts index f32513545a6..917094ee44e 100644 --- a/tests/ci-workflows/package-tree-integrity.test.ts +++ b/tests/ci-workflows/package-tree-integrity.test.ts @@ -355,6 +355,51 @@ console.log(JSON.stringify({ calls })); expect(scheduler.pending).toHaveLength(0); }); + test("a runtime that stops being ready withdraws a settled replacement until it settles again", async () => { + let observation: PackageTreeObservation | null = base; + let clock = 0; + let attempts = 0; + let runtimeReady = true; + const scheduler = createScheduler(); + const guard = createPackageTreeIntegrityGuard( + () => observation, + () => clock, + { + onReplaced: () => { + attempts += 1; + if (attempts === 1) throw new Error("restart unavailable"); + }, + replacedRestartDelayMs: 5_000, + schedule: scheduler.schedule, + readInstalledVersion: () => "9.9.9", + runtimeReady: () => runtimeReady, + }, + ); + + expect(guard.status()).toEqual({ ok: true }); + observation = { ...base, inode: 11n }; + clock += 2_000; + expect(guard.status()).toEqual({ ok: false, reason: "package_tree_replaced" }); + await scheduler.runNext(); + expect(attempts).toBe(1); + expect(guard.installedVersion()).toBe("9.9.9"); + + // A second install puts the bun placeholder back before the admission retry. + runtimeReady = false; + expect(guard.installedVersion()).toBeUndefined(); + await scheduler.runNext(); + expect(attempts).toBe(1); + runtimeReady = true; + expect(guard.installedVersion()).toBeUndefined(); + + await scheduler.runNext(); + expect(attempts).toBe(1); + expect(guard.installedVersion()).toBeUndefined(); + await scheduler.runNext(); + expect(attempts).toBe(2); + expect(guard.installedVersion()).toBe("9.9.9"); + }); + test("baseline recovery cancels the old timer and starts a fresh debounce", async () => { let observation: PackageTreeObservation | null = base; let clock = 0; From 54692d08b985badbe0a7b70f9147101acce4be72 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 23:32:07 +0900 Subject: [PATCH 8/8] test(server): assert the runtime-wait poll and debounce delays --- tests/ci-workflows/package-tree-integrity.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/ci-workflows/package-tree-integrity.test.ts b/tests/ci-workflows/package-tree-integrity.test.ts index 917094ee44e..c63b75cc746 100644 --- a/tests/ci-workflows/package-tree-integrity.test.ts +++ b/tests/ci-workflows/package-tree-integrity.test.ts @@ -204,9 +204,12 @@ describe("package tree integrity", () => { }; const createScheduler = () => { const pending: Array<() => void> = []; + const delays: number[] = []; return { pending, - schedule: (callback: () => void) => { + delays, + schedule: (callback: () => void, delayMs: number) => { + delays.push(delayMs); pending.push(callback); return () => { const index = pending.indexOf(callback); @@ -286,6 +289,8 @@ describe("package tree integrity", () => { expect(calls).toBe(0); await scheduler.runNext(); expect(calls).toBe(1); + // Debounce, two 1s runtime polls, then a fresh full debounce once the runtime is ready. + expect(scheduler.delays).toEqual([5_000, 1_000, 1_000, 5_000]); }); test("the installed guard reads process.execPath as the runtime", async () => {