Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions .github/OPENCLAW_CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 4 additions & 1 deletion src/runtime/test_runner/Execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
42 changes: 40 additions & 2 deletions test/js/bun/test/test-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(`<dir>\\my-test.test.js:5:15`.replace("<dir>", test_dir));
expect(stackLines[0]).toContain(`<dir>\\my-test.test.js:5:11`.replace("<dir>", test_dir));
}
if (process.platform !== "win32") {
expect(stackLines[0]).toContain(`<dir>/my-test.test.js:5:15`.replace("<dir>", test_dir));
expect(stackLines[0]).toContain(`<dir>/my-test.test.js:5:11`.replace("<dir>", 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
Expand Down
122 changes: 74 additions & 48 deletions test/js/node/module/node-module-module.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 };
Expand Down
Loading