Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 4 additions & 1 deletion src/io/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ pub mod parent_death_watchdog {
if !job.is_null() {
let mut jeli: windows::JOBOBJECT_EXTENDED_LIMIT_INFORMATION =
bun_core::ffi::zeroed();
jeli.BasicLimitInformation.LimitFlags = windows::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
// See the constant's doc — Bun's own crash reporter (installed
// via `SetUnhandledExceptionFilter`) still runs before
// `DIE_ON_UNHANDLED_EXCEPTION` applies.
Comment thread
robobun marked this conversation as resolved.
Outdated
jeli.BasicLimitInformation.LimitFlags = windows::JOB_LIMIT_FLAGS_KILL_TREE_ON_CLOSE;
if windows::SetInformationJobObject(
job,
windows::JobObjectExtendedLimitInformation,
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/cli/test/parallel/Coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,7 @@ impl<'a> Coordinator<'a> {
return None;
}
let mut jeli: windows::JOBOBJECT_EXTENDED_LIMIT_INFORMATION = bun_core::ffi::zeroed();
jeli.BasicLimitInformation.LimitFlags = windows::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
jeli.BasicLimitInformation.LimitFlags = windows::JOB_LIMIT_FLAGS_KILL_TREE_ON_CLOSE;
if windows::SetInformationJobObject(
job,
windows::JobObjectExtendedLimitInformation,
Expand Down
13 changes: 13 additions & 0 deletions src/sys/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,19 @@ pub const JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION: DWORD = 0x400;
pub const JOB_OBJECT_LIMIT_BREAKAWAY_OK: DWORD = 0x800;
pub const JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK: DWORD = 0x00001000;

/// `LimitFlags` for a kill-on-close Job that reaps an entire **inherited**
/// descendant tree (`--no-orphans`, the parallel-test coordinator).
/// `DIE_ON_UNHANDLED_EXCEPTION` keeps a crashed descendant from parking on
/// WER; `BREAKAWAY_OK` keeps a descendant's
/// `CreateProcess(CREATE_BREAKAWAY_FROM_JOB)` from failing `ERROR_ACCESS_DENIED`.
/// Deliberately omits `SILENT_BREAKAWAY_OK`: inheritance is the membership
/// mechanism, and that flag would make every child escape the Job. (libuv's
/// global job includes it because libuv assigns each child explicitly; see
/// `uv__init_global_job_handle`.)
Comment thread
robobun marked this conversation as resolved.
Outdated
pub const JOB_LIMIT_FLAGS_KILL_TREE_ON_CLOSE: DWORD = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
| JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION
| JOB_OBJECT_LIMIT_BREAKAWAY_OK;

pub mod rescle {
use super::*;

Expand Down
106 changes: 106 additions & 0 deletions test/cli/run/no-orphans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1089,3 +1089,109 @@ describe.concurrent.each([
}
});
});

// The --no-orphans Job Object is Bun's immediate (leaf) job because enable()
// self-assigns it after startup. Probe its LimitFlags via
// QueryInformationJobObject(NULL), and exercise CREATE_BREAKAWAY_FROM_JOB:
// without BREAKAWAY_OK on the immediate job, that CreateProcess fails with
// ERROR_ACCESS_DENIED (5), which is a compat break --no-orphans mustn't cause.
// DIE_ON_UNHANDLED_EXCEPTION can't be asserted behaviorally in CI (WER is
// suppressed there) so the flag check is the proof for that half.
//
// Spawn via a cmd.exe supervisor so the bun under test is not explicitly
// assigned to the test runner's libuv global job (libuv's job has
// SILENT_BREAKAWAY so cmd.exe's child doesn't inherit it); this guarantees
// the --no-orphans job is the immediate job regardless of parent timing.
test.concurrent.skipIf(!isWindows)(
"windows: --no-orphans Job allows breakaway and sets DIE_ON_UNHANDLED_EXCEPTION, not SILENT_BREAKAWAY",
async () => {
using dir = tempDir("no-orphans-jobflags", {
"probe.js": `
const { dlopen, FFIType, ptr } = require("bun:ffi");
const k = dlopen("kernel32.dll", {
QueryInformationJobObject: {
args: [FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr],
returns: FFIType.i32,
},
GetLastError: { args: [], returns: FFIType.u32 },
CreateProcessW: {
args: [
FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.i32,
FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr,
],
returns: FFIType.i32,
},
WaitForSingleObject: { args: [FFIType.u64, FFIType.u32], returns: FFIType.u32 },
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
}).symbols;

// sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144 on x64/arm64;
// LimitFlags is at offset 16 inside BasicLimitInformation.
const jeli = new Uint8Array(144);
const qok = k.QueryInformationJobObject(null, 9, ptr(jeli), 144, null);
const flags = qok ? new DataView(jeli.buffer).getUint32(16, true) : -1;

const w = s => {
const a = new Uint16Array(s.length + 1);
for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i);
return a;
};
const cmdline = w((process.env.COMSPEC || "C:/Windows/System32/cmd.exe") + " /d /c exit 0");
const si = new Uint8Array(104);
new DataView(si.buffer).setUint32(0, 104, true);
const pi = new Uint8Array(24);
const cok = k.CreateProcessW(null, ptr(cmdline), null, null, 0,
0x01000000 /* CREATE_BREAKAWAY_FROM_JOB */, null, null, ptr(si), ptr(pi));
const cerr = cok ? 0 : k.GetLastError();
if (cok) {
const dv = new DataView(pi.buffer);
const hP = dv.getBigUint64(0, true), hT = dv.getBigUint64(8, true);
k.WaitForSingleObject(hP, 10000);
k.CloseHandle(hP);
k.CloseHandle(hT);
Comment thread
claude[bot] marked this conversation as resolved.
}
process.stdout.write(JSON.stringify({ qok, flags, cok, cerr }));
`,
"sup.bat": `@"${bunExe()}" --no-orphans "%~dp0probe.js"\r\n`,
});
const env: Record<string, string> = { ...bunEnv };
delete env.BUN_FEATURE_FLAG_NO_ORPHANS;
await using proc = Bun.spawn({
cmd: ["cmd.exe", "/d", "/c", `${dir}\\sup.bat`],
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const out = JSON.parse(stdout.trim());

const KILL_ON_JOB_CLOSE = 0x2000;
const DIE_ON_UNHANDLED_EXCEPTION = 0x400;
const BREAKAWAY_OK = 0x800;
const SILENT_BREAKAWAY_OK = 0x1000;

// Sanity: QueryInformationJobObject succeeded and we're looking at the
// --no-orphans job (KILL_ON_JOB_CLOSE set, SILENT_BREAKAWAY_OK not).
// SILENT_BREAKAWAY_OK would defeat --no-orphans entirely: children
// would no longer inherit Job membership.
expect({
qok: out.qok,
KILL_ON_JOB_CLOSE: !!(out.flags & KILL_ON_JOB_CLOSE),
DIE_ON_UNHANDLED_EXCEPTION: !!(out.flags & DIE_ON_UNHANDLED_EXCEPTION),
BREAKAWAY_OK: !!(out.flags & BREAKAWAY_OK),
SILENT_BREAKAWAY_OK: !!(out.flags & SILENT_BREAKAWAY_OK),
}).toEqual({
qok: 1,
KILL_ON_JOB_CLOSE: true,
DIE_ON_UNHANDLED_EXCEPTION: true,
BREAKAWAY_OK: true,
SILENT_BREAKAWAY_OK: false,
});

// Behavioral: a descendant's CreateProcess(CREATE_BREAKAWAY_FROM_JOB) must
// succeed. Before the fix this was { cok: 0, cerr: 5 } (ERROR_ACCESS_DENIED).
expect({ cok: out.cok, cerr: out.cerr }).toEqual({ cok: 1, cerr: 0 });
expect(exitCode).toBe(0);
},
);
Loading