diff --git a/.github/OPENCLAW_CI.md b/.github/OPENCLAW_CI.md index bd15c5777c7e..5b0174c0e6b4 100644 --- a/.github/OPENCLAW_CI.md +++ b/.github/OPENCLAW_CI.md @@ -157,8 +157,28 @@ tests. The same failure reproduces with continuous cache-file progress under Microsoft Defender. The Windows rename helper requested `FILE_TRAVERSE`, which is `FILE_EXECUTE` for a regular file, forcing synchronous executable-file scans for each cache entry. The helper now uses its existing non-executable access -rights directly. The test workloads, deadlines, idle-generation window, and -signal-exit budget are unchanged. +rights directly in [PR #130](https://github.com/openclaw/bun/pull/130), without +changing the test workloads, deadlines, idle-generation window, or signal-exit budget. + +The deferred-idle test hit its outer 30-second deadline again in Windows x64 +[run 37386790583](https://github.com/openclaw/bun/actions/runs/37386790583), with +#130 present. That run retained no progress timeline, so its runner-level cause +is unknown. On a separate Windows x64 host with Defender enabled, limiting the +exact binary's CPU budget reproduced the timeout while entries kept increasing. +An independent driver completed all 2,002 entries in 64.7 seconds; suspending the +child instead triggered the existing ten-second no-progress guard. + +Using a progress-only deadline also exposed a test-runner bug: a stale file +timer treated the unlimited entry's zero timestamp as expired and killed its +child. The process-reaping check now excludes the no-deadline sentinel, matching +the entry's timeout check. A separate regression covers the completed-deadline +and live-child sequence. + +The test now bounds module loading, pipe flushes, and child exit individually +and keeps the ten-second persistence stall guard. It has no aggregate duration +limit: slow, steadily increasing entry counts satisfy the idle-progress +contract. The 2,000-module workload, late-module handshake, expected 2,002 files, +idle-generation window, and signal-exit budget are unchanged. Keep later failures distinguishable from these causes; a green rerun does not establish a cause. The native lane has no automatic test retries or exclusion diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2335532e3e..715024970a92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -222,3 +222,7 @@ - Load CommonJS native addons through Windows namespaced paths so long installation paths work with addon activation contexts, while preserving module cache keys and embedded-addon extraction. - Resolve Windows namespaced CommonJS paths without treating the device prefix as a query, preserving Node's distinct cache keys and native-addon loading. + +- Detect deferred compile-cache test stalls by persistence progress, with bounded startup and shutdown, so slow Windows runners can complete the unchanged workload. + +- Preserve an unlimited test's child processes when a completed test's stale deadline fires. diff --git a/src/runtime/test_runner/Execution.rs b/src/runtime/test_runner/Execution.rs index 91139275776e..c85bf86d6b36 100644 --- a/src/runtime/test_runner/Execution.rs +++ b/src/runtime/test_runner/Execution.rs @@ -319,7 +319,10 @@ impl Execution { // SAFETY: arena-owned entry, alive for lifetime of BunTest let entry = unsafe { entry.as_ref() }; let now = Timespec::now_force_real_time(); - if entry.timespec.order(&now) == core::cmp::Ordering::Less { + // A stale file timer can fire while an unlimited entry is active. + if !entry.timespec.eql(&Timespec::EPOCH) + && entry.timespec.order(&now) == core::cmp::Ordering::Less + { // SAFETY: bun_vm() returns the live per-thread VM. let kill_count = global_this.bun_vm().as_mut().auto_killer.kill(); if kill_count.processes > 0 { diff --git a/test/js/bun/test/test-test.test.ts b/test/js/bun/test/test-test.test.ts index 1fad55319b9b..07994f0e744c 100644 --- a/test/js/bun/test/test-test.test.ts +++ b/test/js/bun/test/test-test.test.ts @@ -554,6 +554,44 @@ it("test timeouts when expected", () => { expect(err).not.toContain("unreachable code"); }); +test("a completed deadline does not kill an unlimited test's child", async () => { + using dir = tempDir("unlimited-test-child", { + "child.test.js": ` + import { afterAll, beforeAll, expect, test } from "bun:test"; + let child; + beforeAll(() => { + child = Bun.spawn([process.execPath, "-e", "setInterval(() => {}, 1000)"], { + stdin: "ignore", + stdout: "ignore", + stderr: "inherit", + }); + }); + afterAll(() => { + child.kill(); + child.unref(); + }); + test("completed finite deadline", () => {}, 50); + test("unlimited test retains its child", async () => { + // Cross the completed test's deadline while this entry owns a live child. + await Bun.sleep(100); + expect(() => process.kill(child.pid, 0)).not.toThrow(); + }, 0); + `, + }); + await using proc = spawn({ + cmd: [bunExe(), "test", "child.test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("2 pass"); + expect(stderr).toContain("0 fail"); + expect(stderr).not.toContain("killed"); +}); + test("jest.setTimeout will change default timeout", () => { const path = join(tmp, "jest-setTimeout-test.test.js"); copyFileSync(join(import.meta.dir, "setTimeout-test-fixture.js"), path); @@ -735,10 +773,10 @@ test("my-test", () => { const stackLines = output.split("\n").filter(line => line.trim().startsWith("at ")); expect(stackLines.length).toBeGreaterThan(0); if (process.platform === "win32") { - expect(stackLines[0]).toContain(`\\my-test.test.js:5:15`.replace("", test_dir)); + expect(stackLines[0]).toContain(`\\my-test.test.js:5:11`.replace("", test_dir)); } if (process.platform !== "win32") { - expect(stackLines[0]).toContain(`/my-test.test.js:5:15`.replace("", test_dir)); + expect(stackLines[0]).toContain(`/my-test.test.js:5:11`.replace("", test_dir)); } expect(output).toContain("1 pass"); // since the error is unhandled and in a hook, the error does not get attributed to the hook and the test is still allowed to run diff --git a/test/js/node/module/node-module-module.test.js b/test/js/node/module/node-module-module.test.js index 7d3b0ae1e93e..21221ea9e414 100644 --- a/test/js/node/module/node-module-module.test.js +++ b/test/js/node/module/node-module-module.test.js @@ -935,7 +935,7 @@ console.log("survived", require("./late.js"));`, `, }); const cacheDir = path.join(String(dir), "cc"); - await using proc = Bun.spawn({ + const proc = Bun.spawn({ cmd: [bunExe(), "main.cjs"], cwd: String(dir), env: { @@ -949,57 +949,83 @@ console.log("survived", require("./late.js"));`, stdout: "pipe", stderr: "pipe", }); - const ready = Promise.withResolvers(); - const stdoutPromise = (async () => { - let text = ""; - for await (const chunk of proc.stdout) { - text += Buffer.from(chunk).toString(); - if (text.startsWith("ready\n")) ready.resolve(); + const waitForPhase = async (promise, phase) => { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`compile cache ${phase} stalled (exitCode: ${proc.exitCode})`)); + }, 10_000); + }), + ]); + } finally { + clearTimeout(timer); } - return text; - })(); - const stderrPromise = proc.stderr.text(); - await Promise.race([ - ready.promise, - proc.exited.then(() => { - throw new Error("exited before ready"); - }), - ]); - const files = () => - [...new Bun.Glob("**/*").scanSync({ cwd: cacheDir, onlyFiles: true })].filter(f => - /^[0-9a-f]{16}$/.test(path.basename(f)), - ).length; - const waitForFiles = async minimum => { - let deadline = Date.now() + 10_000; - let previous = 0; - while (true) { - const current = files(); - if (current >= minimum) return; - // Detect stranded work without timing out slow, low-priority compilation. - if (current > previous) { - previous = current; - deadline = Date.now() + 10_000; + }; + try { + const ready = Promise.withResolvers(); + const stdoutPromise = (async () => { + let text = ""; + for await (const chunk of proc.stdout) { + text += Buffer.from(chunk).toString(); + if (text.startsWith("ready\n")) ready.resolve(); } - if (Date.now() >= deadline || proc.exitCode !== null) { - throw new Error(`idle persistence stalled at ${current}/${minimum} entries (exitCode: ${proc.exitCode})`); + return text; + })(); + const stderrPromise = proc.stderr.text(); + await waitForPhase( + Promise.race([ + ready.promise, + proc.exited.then(() => { + throw new Error("exited before ready"); + }), + ]), + "module loading", + ); + const files = () => + [...new Bun.Glob("**/*").scanSync({ cwd: cacheDir, onlyFiles: true })].filter(f => + /^[0-9a-f]{16}$/.test(path.basename(f)), + ).length; + const waitForFiles = async minimum => { + let deadline = Date.now() + 10_000; + let previous = 0; + while (true) { + const current = files(); + if (current >= minimum) return; + // Detect stranded work without timing out slow, low-priority compilation. + if (current > previous) { + previous = current; + deadline = Date.now() + 10_000; + } + if (Date.now() >= deadline || proc.exitCode !== null) { + throw new Error(`idle persistence stalled at ${current}/${minimum} entries (exitCode: ${proc.exitCode})`); + } + await Bun.sleep(10); } - await Bun.sleep(10); - } - }; - await waitForFiles(1); - expect(files()).toBeLessThan(count + 1); - proc.stdin.write("late\n"); - await proc.stdin.flush(); - // The child now has no JS timers. The new module must wake/resume deferred persistence. - await waitForFiles(count + 2); - proc.stdin.write("stop\n"); - await proc.stdin.flush(); - const [stdout, stderr, exitCode] = await Promise.all([stdoutPromise, stderrPromise, proc.exited]); - expect(stdout.trim()).toBe("ready\nlate\ncomplete"); - expect(stderr).toBe(""); - expect(exitCode).toBe(0); + }; + await waitForFiles(1); + expect(files()).toBeLessThan(count + 1); + proc.stdin.write("late\n"); + await waitForPhase(proc.stdin.flush(), "late-module request"); + // The child now has no JS timers. The new module must wake/resume deferred persistence. + await waitForFiles(count + 2); + proc.stdin.write("stop\n"); + await waitForPhase(proc.stdin.flush(), "stop request"); + const [stdout, stderr, exitCode] = await waitForPhase( + Promise.all([stdoutPromise, stderrPromise, proc.exited]), + "child exit", + ); + expect(stdout.trim()).toBe("ready\nlate\ncomplete"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + } finally { + await waitForPhase(proc[Symbol.asyncDispose](), "child cleanup"); + } }, - 30_000, + // Each awaited phase is bounded above; steady persistence is independent of runner speed. + 0, ); const compileCacheEnv = { ...bunEnv };