From 9ae7e91c47084331b8ea7ab7dd61cbd264ffc173 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:29:25 +0000 Subject: [PATCH 1/8] worker_threads: honor --no-addons in execArgv after value-taking flags The hand-rolled execArgv scanner in parse_worker_exec_argv_allow_addons treated every non-'-' token as the first positional and stopped there, without knowing the preceding flag may consume it as a value. So execArgv: ['-r', './preload.js', '--no-addons'] stopped at './preload.js' and left addons enabled. Same for --title, --port, -e, --require, etc. Consult RUN_PARAMS for the previous flag's takes_value (One/Many) and skip the next token when it is a value, matching the Zig clap parse that WebWorker.startVM used. --- src/runtime/jsc_hooks.rs | 37 +++++++++++++++++-- .../worker_threads/worker_threads.test.ts | 26 +++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index a56936359a69..e67415eeb85f 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1457,8 +1457,10 @@ unsafe fn apply_standalone_runtime_flags( /// reads the single `--no-addons` flag from the result (per the in-tree /// `// TODO: currently this only checks for --no-addons`), so this body scans /// the converted argv directly with the same `stop_after_positional_at = 1` -/// short-circuit. Full clap routing can return when `ComptimeClap` grows a -/// borrowed-lifetime variant. +/// short-circuit — consulting `RUN_PARAMS` so a value token following a +/// value-taking flag (`-r x`, `--title x`, …) is consumed rather than treated +/// as the first positional. Full clap routing can return when `ComptimeClap` +/// grows a borrowed-lifetime variant. /// /// # Safety /// Each `WTFStringImpl` in `exec_argv` is a live WTF string (the C++ @@ -1466,7 +1468,30 @@ unsafe fn apply_standalone_runtime_flags( unsafe fn parse_worker_exec_argv_allow_addons( exec_argv: &[bun_core::WTFStringImpl], ) -> Option { + use crate::cli::arguments::RUN_PARAMS; + use bun_clap::Values; + + // Does `bytes` name a `RUN_PARAMS` flag whose value is supplied by the + // *next* token? True only for the bare `--long` / `-s` spellings of a + // `One`/`Many` param; `--long=val`, `-s=val`, `-sval`, chained shorts and + // `OneOptional` params all carry their value (if any) inline and do not + // pull from the iterator in `StreamingClap`. + fn flag_consumes_next_token(bytes: &[u8]) -> bool { + let param = if let Some(long) = bytes.strip_prefix(b"--") { + RUN_PARAMS.iter().find(|p| p.names.matches_long(long)) + } else if bytes.len() == 2 && bytes[0] == b'-' { + RUN_PARAMS.iter().find(|p| p.names.short == Some(bytes[1])) + } else { + None + }; + matches!( + param.map(|p| p.takes_value), + Some(Values::One | Values::Many) + ) + } + let mut no_addons = false; + let mut prev_wants_value = false; for &arg in exec_argv { if arg.is_null() { continue; @@ -1474,8 +1499,13 @@ unsafe fn parse_worker_exec_argv_allow_addons( // SAFETY: per fn contract — `arg` is a live `WTFStringImpl*`. let owned = unsafe { &*arg }.to_owned_slice_z(); let bytes = owned.as_bytes(); - // `stop_after_positional_at = 1` — first non-flag token ends parsing. + // `stop_after_positional_at = 1` — first positional ends parsing. A + // non-`-` token that the previous flag consumes as its value is not a + // positional. if bytes.first() != Some(&b'-') { + if core::mem::take(&mut prev_wants_value) { + continue; + } break; } if bytes == b"--" { @@ -1484,6 +1514,7 @@ unsafe fn parse_worker_exec_argv_allow_addons( if bytes == b"--no-addons" { no_addons = true; } + prev_wants_value = flag_consumes_next_token(bytes); } // Override `allow_addons` unconditionally on successful parse. Some(!no_addons) diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 555c205c6fc7..c8efea57bef5 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -278,6 +278,32 @@ describe("execArgv option", async () => { await run('["--no-warnings"]', '["--no-warnings"]\n'); }); // TODO(@190n) get our handling of non-string array elements in line with Node's + + describe("--no-addons is honored after a value-taking flag", () => { + // The value token for `-r` / `--title` / `--port` / `-e` must not be + // treated as the first positional when parsing the worker's execArgv. + const body = `try { process.dlopen({ exports: {} }, "/nonexistent.node"); } catch (e) { require("node:worker_threads").parentPort.postMessage(e.code); }`; + it.each([ + [["--no-addons"]], + [["-r", "./preload.js", "--no-addons"]], + [["--require", "./preload.js", "--no-addons"]], + [["--title", "foo", "--no-addons"]], + [["--port", "3000", "--no-addons"]], + [["-e", "void 0", "--no-addons"]], + [["--no-addons", "-r", "./preload.js"]], + ])("%j", async execArgv => { + const worker = new Worker(body, { eval: true, execArgv }); + try { + const code = await new Promise((resolve, reject) => { + worker.on("message", resolve); + worker.on("error", reject); + }); + expect(code).toBe("ERR_DLOPEN_DISABLED"); + } finally { + await worker.terminate(); + } + }); + }); }); test("eval does not leak source code", async () => { From 58baa3190838f541b30cfc76ced5bb8b0e685659 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:43:14 +0000 Subject: [PATCH 2/8] move worker execArgv --no-addons tests to no-addons.test.ts --- test/js/node/no-addons.test.ts | 29 ++++++++++++++++++- .../worker_threads/worker_threads.test.ts | 26 ----------------- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/test/js/node/no-addons.test.ts b/test/js/node/no-addons.test.ts index 0df8cf5155e2..e8f19c94e012 100644 --- a/test/js/node/no-addons.test.ts +++ b/test/js/node/no-addons.test.ts @@ -1,6 +1,7 @@ import { spawnSync } from "bun"; -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunExe, bunEnv as env } from "harness"; +import { Worker } from "node:worker_threads"; test("--no-addons throws an error on process.dlopen", () => { const { stdout, stderr, exitCode } = spawnSync({ @@ -15,3 +16,29 @@ test("--no-addons throws an error on process.dlopen", () => { expect(out).toBeEmpty(); expect(err).toContain("\nerror: Cannot load native addon because loading addons is disabled."); }); + +describe("worker execArgv honors --no-addons after value-taking flags", () => { + // The value token for `-r` / `--title` / `--port` / `-e` must not be + // treated as the first positional when parsing the worker's execArgv. + const body = `try { process.dlopen({ exports: {} }, "/nonexistent.node"); } catch (e) { require("node:worker_threads").parentPort.postMessage(e.code); }`; + test.each([ + [["--no-addons"]], + [["-r", "./preload.js", "--no-addons"]], + [["--require", "./preload.js", "--no-addons"]], + [["--title", "foo", "--no-addons"]], + [["--port", "3000", "--no-addons"]], + [["-e", "void 0", "--no-addons"]], + [["--no-addons", "-r", "./preload.js"]], + ])("%j", async execArgv => { + const worker = new Worker(body, { eval: true, execArgv }); + try { + const code = await new Promise((resolve, reject) => { + worker.on("message", resolve); + worker.on("error", reject); + }); + expect(code).toBe("ERR_DLOPEN_DISABLED"); + } finally { + await worker.terminate(); + } + }); +}); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index c8efea57bef5..555c205c6fc7 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -278,32 +278,6 @@ describe("execArgv option", async () => { await run('["--no-warnings"]', '["--no-warnings"]\n'); }); // TODO(@190n) get our handling of non-string array elements in line with Node's - - describe("--no-addons is honored after a value-taking flag", () => { - // The value token for `-r` / `--title` / `--port` / `-e` must not be - // treated as the first positional when parsing the worker's execArgv. - const body = `try { process.dlopen({ exports: {} }, "/nonexistent.node"); } catch (e) { require("node:worker_threads").parentPort.postMessage(e.code); }`; - it.each([ - [["--no-addons"]], - [["-r", "./preload.js", "--no-addons"]], - [["--require", "./preload.js", "--no-addons"]], - [["--title", "foo", "--no-addons"]], - [["--port", "3000", "--no-addons"]], - [["-e", "void 0", "--no-addons"]], - [["--no-addons", "-r", "./preload.js"]], - ])("%j", async execArgv => { - const worker = new Worker(body, { eval: true, execArgv }); - try { - const code = await new Promise((resolve, reject) => { - worker.on("message", resolve); - worker.on("error", reject); - }); - expect(code).toBe("ERR_DLOPEN_DISABLED"); - } finally { - await worker.terminate(); - } - }); - }); }); test("eval does not leak source code", async () => { From 348c9421c2da53652efe1ca5b1b06c990fc34381 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:52:55 +0000 Subject: [PATCH 3/8] consume value tokens unconditionally; handle chained-short clusters Hoist the prev_wants_value check above the '-' prefix and '--' terminator checks so a value token is consumed via raw iter.next() regardless of its content, matching StreamingClap. Walk chained-short clusters (-br) so a One/Many short at the end of the cluster pulls the next token. --- src/runtime/jsc_hooks.rs | 59 ++++++++++++++++++++++------------ test/js/node/no-addons.test.ts | 32 +++++++++++------- 2 files changed, 58 insertions(+), 33 deletions(-) diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index e67415eeb85f..10b04df85d24 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1471,23 +1471,40 @@ unsafe fn parse_worker_exec_argv_allow_addons( use crate::cli::arguments::RUN_PARAMS; use bun_clap::Values; + fn find_short(c: u8) -> Option<&'static bun_clap::Param> { + RUN_PARAMS.iter().find(|p| p.names.short == Some(c)) + } + // Does `bytes` name a `RUN_PARAMS` flag whose value is supplied by the - // *next* token? True only for the bare `--long` / `-s` spellings of a - // `One`/`Many` param; `--long=val`, `-s=val`, `-sval`, chained shorts and - // `OneOptional` params all carry their value (if any) inline and do not - // pull from the iterator in `StreamingClap`. + // *next* token? `--long=val` and `OneOptional` params never pull from the + // iterator. A bare `--long` / `-s` pulls when its `takes_value` is + // `One`/`Many`. A chained-short cluster (`-br`) pulls when every prefix + // char is a `None`/`OneOptional` short and the last char is a + // `One`/`Many` short with nothing after it — `StreamingClap::chainging` + // calls `iter.next()` in exactly that case. fn flag_consumes_next_token(bytes: &[u8]) -> bool { - let param = if let Some(long) = bytes.strip_prefix(b"--") { - RUN_PARAMS.iter().find(|p| p.names.matches_long(long)) - } else if bytes.len() == 2 && bytes[0] == b'-' { - RUN_PARAMS.iter().find(|p| p.names.short == Some(bytes[1])) - } else { - None - }; - matches!( - param.map(|p| p.takes_value), - Some(Values::One | Values::Many) - ) + if let Some(long) = bytes.strip_prefix(b"--") { + let param = RUN_PARAMS.iter().find(|p| p.names.matches_long(long)); + return matches!( + param.map(|p| p.takes_value), + Some(Values::One | Values::Many) + ); + } + if let Some(shorts) = bytes.strip_prefix(b"-") { + for (i, &c) in shorts.iter().enumerate() { + let Some(param) = find_short(c) else { + return false; + }; + match param.takes_value { + Values::None | Values::OneOptional => continue, + // `One`/`Many`: pulls from the iterator only when this is + // the last char; otherwise the rest of the cluster (or + // the text after `=`) is the inline value. + Values::One | Values::Many => return i + 1 == shorts.len(), + } + } + } + false } let mut no_addons = false; @@ -1499,13 +1516,13 @@ unsafe fn parse_worker_exec_argv_allow_addons( // SAFETY: per fn contract — `arg` is a live `WTFStringImpl*`. let owned = unsafe { &*arg }.to_owned_slice_z(); let bytes = owned.as_bytes(); - // `stop_after_positional_at = 1` — first positional ends parsing. A - // non-`-` token that the previous flag consumes as its value is not a - // positional. + // The previous `One`/`Many` flag consumes this token as its value via + // raw `iter.next()` — regardless of a leading `-` or a literal `--`. + if core::mem::take(&mut prev_wants_value) { + continue; + } + // `stop_after_positional_at = 1` — first positional ends parsing. if bytes.first() != Some(&b'-') { - if core::mem::take(&mut prev_wants_value) { - continue; - } break; } if bytes == b"--" { diff --git a/test/js/node/no-addons.test.ts b/test/js/node/no-addons.test.ts index e8f19c94e012..1201d4f4b818 100644 --- a/test/js/node/no-addons.test.ts +++ b/test/js/node/no-addons.test.ts @@ -17,26 +17,34 @@ test("--no-addons throws an error on process.dlopen", () => { expect(err).toContain("\nerror: Cannot load native addon because loading addons is disabled."); }); -describe("worker execArgv honors --no-addons after value-taking flags", () => { - // The value token for `-r` / `--title` / `--port` / `-e` must not be - // treated as the first positional when parsing the worker's execArgv. +describe("worker execArgv --no-addons parsing matches RunCommand clap", () => { + // A value token following a value-taking flag (`-r x`, `--title x`, ...) + // must not be treated as the first positional when scanning execArgv, and + // the value is consumed regardless of its own content (even `--` or a + // `-`-prefixed string). const body = `try { process.dlopen({ exports: {} }, "/nonexistent.node"); } catch (e) { require("node:worker_threads").parentPort.postMessage(e.code); }`; test.each([ - [["--no-addons"]], - [["-r", "./preload.js", "--no-addons"]], - [["--require", "./preload.js", "--no-addons"]], - [["--title", "foo", "--no-addons"]], - [["--port", "3000", "--no-addons"]], - [["-e", "void 0", "--no-addons"]], - [["--no-addons", "-r", "./preload.js"]], - ])("%j", async execArgv => { + [["--no-addons"], "ERR_DLOPEN_DISABLED"], + [["-r", "./preload.js", "--no-addons"], "ERR_DLOPEN_DISABLED"], + [["--require", "./preload.js", "--no-addons"], "ERR_DLOPEN_DISABLED"], + [["--title", "foo", "--no-addons"], "ERR_DLOPEN_DISABLED"], + [["--port", "3000", "--no-addons"], "ERR_DLOPEN_DISABLED"], + [["-e", "void 0", "--no-addons"], "ERR_DLOPEN_DISABLED"], + [["--no-addons", "-r", "./preload.js"], "ERR_DLOPEN_DISABLED"], + // chained shorts ending in a value-taking short pull the next token + [["-br", "./preload.js", "--no-addons"], "ERR_DLOPEN_DISABLED"], + // the value is consumed via raw iter.next(), even if it is `--` + [["-r", "--", "--no-addons"], "ERR_DLOPEN_DISABLED"], + // here `--no-addons` is `-r`'s value, not a flag; addons stay enabled + [["-r", "--no-addons"], "ERR_DLOPEN_FAILED"], + ])("%j", async (execArgv, expected) => { const worker = new Worker(body, { eval: true, execArgv }); try { const code = await new Promise((resolve, reject) => { worker.on("message", resolve); worker.on("error", reject); }); - expect(code).toBe("ERR_DLOPEN_DISABLED"); + expect(code).toBe(expected); } finally { await worker.terminate(); } From fd798827294b997f6707f0e0cbcdeb16ac79d416 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:58:20 +0000 Subject: [PATCH 4/8] test: reject on worker exit before message --- test/js/node/no-addons.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/js/node/no-addons.test.ts b/test/js/node/no-addons.test.ts index 1201d4f4b818..04587565ff95 100644 --- a/test/js/node/no-addons.test.ts +++ b/test/js/node/no-addons.test.ts @@ -41,8 +41,11 @@ describe("worker execArgv --no-addons parsing matches RunCommand clap", () => { const worker = new Worker(body, { eval: true, execArgv }); try { const code = await new Promise((resolve, reject) => { - worker.on("message", resolve); - worker.on("error", reject); + worker.once("message", resolve); + worker.once("error", reject); + worker.once("exit", exitCode => + reject(new Error(`worker exited (code=${exitCode}) before posting a message`)), + ); }); expect(code).toBe(expected); } finally { From 39a516df459655e28e64153d49c9e41a77f5ac26 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:00:31 +0000 Subject: [PATCH 5/8] [autofix.ci] apply automated fixes --- test/js/node/no-addons.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/js/node/no-addons.test.ts b/test/js/node/no-addons.test.ts index 04587565ff95..98343f807238 100644 --- a/test/js/node/no-addons.test.ts +++ b/test/js/node/no-addons.test.ts @@ -43,9 +43,7 @@ describe("worker execArgv --no-addons parsing matches RunCommand clap", () => { const code = await new Promise((resolve, reject) => { worker.once("message", resolve); worker.once("error", reject); - worker.once("exit", exitCode => - reject(new Error(`worker exited (code=${exitCode}) before posting a message`)), - ); + worker.once("exit", exitCode => reject(new Error(`worker exited (code=${exitCode}) before posting a message`))); }); expect(code).toBe(expected); } finally { From 292adbb1a2500e5f9fbb4e0f77dcef82341cad95 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:12:35 +0000 Subject: [PATCH 6/8] stop chaining at OneOptional shorts; treat bare '-' as positional StreamingClap::chainging stops chaining when takes_value != None, so a OneOptional short terminates the cluster without pulling from the iterator. StreamingClap::parse_next_arg classifies both '--' and '-' as positionals. --- src/runtime/jsc_hooks.rs | 19 +++++++++++-------- test/js/node/no-addons.test.ts | 2 ++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 10b04df85d24..3b3a32c87168 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1479,9 +1479,9 @@ unsafe fn parse_worker_exec_argv_allow_addons( // *next* token? `--long=val` and `OneOptional` params never pull from the // iterator. A bare `--long` / `-s` pulls when its `takes_value` is // `One`/`Many`. A chained-short cluster (`-br`) pulls when every prefix - // char is a `None`/`OneOptional` short and the last char is a - // `One`/`Many` short with nothing after it — `StreamingClap::chainging` - // calls `iter.next()` in exactly that case. + // char is a `None` short and the last char is a `One`/`Many` short with + // nothing after it — `StreamingClap::chainging` calls `iter.next()` in + // exactly that case. fn flag_consumes_next_token(bytes: &[u8]) -> bool { if let Some(long) = bytes.strip_prefix(b"--") { let param = RUN_PARAMS.iter().find(|p| p.names.matches_long(long)); @@ -1496,7 +1496,11 @@ unsafe fn parse_worker_exec_argv_allow_addons( return false; }; match param.takes_value { - Values::None | Values::OneOptional => continue, + Values::None => continue, + // `StreamingClap::chainging` stops chaining at a + // `OneOptional` short without pulling from the iterator; + // remaining cluster chars are dropped. + Values::OneOptional => return false, // `One`/`Many`: pulls from the iterator only when this is // the last char; otherwise the rest of the cluster (or // the text after `=`) is the inline value. @@ -1522,10 +1526,9 @@ unsafe fn parse_worker_exec_argv_allow_addons( continue; } // `stop_after_positional_at = 1` — first positional ends parsing. - if bytes.first() != Some(&b'-') { - break; - } - if bytes == b"--" { + // `StreamingClap::parse_next_arg` treats bare `-` and `--` as + // positionals. + if bytes.first() != Some(&b'-') || bytes == b"-" || bytes == b"--" { break; } if bytes == b"--no-addons" { diff --git a/test/js/node/no-addons.test.ts b/test/js/node/no-addons.test.ts index 98343f807238..e58026be2ff6 100644 --- a/test/js/node/no-addons.test.ts +++ b/test/js/node/no-addons.test.ts @@ -37,6 +37,8 @@ describe("worker execArgv --no-addons parsing matches RunCommand clap", () => { [["-r", "--", "--no-addons"], "ERR_DLOPEN_DISABLED"], // here `--no-addons` is `-r`'s value, not a flag; addons stay enabled [["-r", "--no-addons"], "ERR_DLOPEN_FAILED"], + // bare `-` is a positional; parsing stops before `--no-addons` + [["-", "--no-addons"], "ERR_DLOPEN_FAILED"], ])("%j", async (execArgv, expected) => { const worker = new Worker(body, { eval: true, execArgv }); try { From df7c4c9de0f98aefc2a79184f73df4bb6cd9957a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:07:09 +0000 Subject: [PATCH 7/8] use the real clap parser for worker execArgv Generalize ComptimeClap/Args over the argv lifetime so parse_ex accepts a borrowed SliceIterator, and rewrite parse_worker_exec_argv_allow_addons as a direct port of the Zig startVM path: convert the WTF strings to owned UTF-8, run parse_ex against RUN_PARAMS with stop_after_positional_at = 1, read args.flag("--no-addons"). Existing OsIterator callers infer 'static via elision. --- src/clap/comptime.rs | 43 +++++++++-------- src/clap/lib.rs | 26 +++++----- src/runtime/jsc_hooks.rs | 100 ++++++++------------------------------- 3 files changed, 56 insertions(+), 113 deletions(-) diff --git a/src/clap/comptime.rs b/src/clap/comptime.rs index 345ddad22196..dae4b5c1e1ff 100644 --- a/src/clap/comptime.rs +++ b/src/clap/comptime.rs @@ -504,13 +504,14 @@ pub fn convert_params(params: &[Param]) -> (Vec>, usize, us // ───────────────────────────────────────────────────────────────────────────── /// Deprecated: Use `parse_ex` instead -pub struct ComptimeClap { - // Inner `&'static [u8]` slices borrow argv (process-lifetime). - pub single_options: Box<[Option<&'static [u8]>]>, - pub multi_options: Box<[Box<[&'static [u8]]>]>, +pub struct ComptimeClap<'a, Id> { + // Inner `&'a [u8]` slices borrow argv. `'a` is `'static` for `OsIterator` + // (process argv); a borrowed `SliceIterator` gets its own lifetime. + pub single_options: Box<[Option<&'a [u8]>]>, + pub multi_options: Box<[Box<[&'a [u8]]>]>, pub flags: Box<[bool]>, - pub pos: Box<[&'static [u8]]>, - pub passthrough_positionals: Box<[&'static [u8]]>, + pub pos: Box<[&'a [u8]]>, + pub passthrough_positionals: Box<[&'a [u8]]>, // The converted params are // carried as a `&'static` table — either rodata (`comptime_table!`) or @@ -520,9 +521,9 @@ pub struct ComptimeClap { _id: PhantomData, } -impl ComptimeClap { - /// `iter` must yield `&'static [u8]` (process-lifetime args, e.g. `OsIterator`) - /// because parsed values are stored by reference. +impl<'a, Id> ComptimeClap<'a, Id> { + /// Parsed values are stored by reference into the `&'a [u8]` slices + /// yielded by `iter`; `'a` is `'static` for `OsIterator` (process argv). /// /// `params` must be `'static` (every in-tree table is a `static`/`const` /// item); the converted form is interned once per unique slice. @@ -540,7 +541,7 @@ impl ComptimeClap { opt: ParseOptions<'_>, ) -> Result where - I: ArgIter<'static>, + I: ArgIter<'a>, { Self::parse_with_table(ConvertedTable::for_params(params), iter, opt) } @@ -553,15 +554,15 @@ impl ComptimeClap { opt: ParseOptions<'_>, ) -> Result where - I: ArgIter<'static>, + I: ArgIter<'a>, { // `opt.allocator` dropped — global mimalloc. - let mut multis: Vec> = (0..table.n_multi).map(|_| Vec::new()).collect(); + let mut multis: Vec> = (0..table.n_multi).map(|_| Vec::new()).collect(); - let mut pos: Vec<&'static [u8]> = Vec::new(); - let mut passthrough_positionals: Vec<&'static [u8]> = Vec::new(); + let mut pos: Vec<&'a [u8]> = Vec::new(); + let mut passthrough_positionals: Vec<&'a [u8]> = Vec::new(); - let mut single_options: Box<[Option<&'static [u8]>]> = + let mut single_options: Box<[Option<&'a [u8]>]> = vec![None; table.n_single].into_boxed_slice(); let mut flags: Box<[bool]> = vec![false; table.n_flags].into_boxed_slice(); @@ -637,7 +638,7 @@ impl ComptimeClap { } #[inline] - pub fn option(&self, name: &[u8]) -> Option<&'static [u8]> { + pub fn option(&self, name: &[u8]) -> Option<&'a [u8]> { let param = self.table.find(name); debug_assert!( param.takes_value != Values::None, @@ -653,7 +654,7 @@ impl ComptimeClap { } #[inline] - pub fn options(&self, name: &[u8]) -> &[&'static [u8]] { + pub fn options(&self, name: &[u8]) -> &[&'a [u8]] { let param = self.table.find(name); debug_assert!( param.takes_value != Values::None, @@ -675,19 +676,19 @@ impl ComptimeClap { self.flags[self.table.converted[converted_idx].id] } #[inline] - pub fn option_at(&self, converted_idx: usize) -> Option<&'static [u8]> { + pub fn option_at(&self, converted_idx: usize) -> Option<&'a [u8]> { self.single_options[self.table.converted[converted_idx].id] } #[inline] - pub fn options_at(&self, converted_idx: usize) -> &[&'static [u8]] { + pub fn options_at(&self, converted_idx: usize) -> &[&'a [u8]] { &self.multi_options[self.table.converted[converted_idx].id] } - pub fn positionals(&self) -> &[&'static [u8]] { + pub fn positionals(&self) -> &[&'a [u8]] { &self.pos } - pub fn remaining(&self) -> &[&'static [u8]] { + pub fn remaining(&self) -> &[&'a [u8]] { &self.passthrough_positionals } diff --git a/src/clap/lib.rs b/src/clap/lib.rs index 768234df2d61..7b8fc2e84003 100644 --- a/src/clap/lib.rs +++ b/src/clap/lib.rs @@ -455,29 +455,29 @@ fn get_value_simple(param: &Param) -> &'static [u8] { param.id.value } -pub struct Args { - pub clap: ComptimeClap, - pub exe_arg: Option<&'static [u8]>, +pub struct Args<'a, Id: 'static> { + pub clap: ComptimeClap<'a, Id>, + pub exe_arg: Option<&'a [u8]>, } -impl Args { +impl<'a, Id: 'static> Args<'a, Id> { pub fn flag(&self, name: &'static [u8]) -> bool { self.clap.flag(name) } - pub fn option(&self, name: &'static [u8]) -> Option<&'static [u8]> { + pub fn option(&self, name: &'static [u8]) -> Option<&'a [u8]> { self.clap.option(name) } - pub fn options(&self, name: &'static [u8]) -> &[&'static [u8]] { + pub fn options(&self, name: &'static [u8]) -> &[&'a [u8]] { self.clap.options(name) } - pub fn positionals(&self) -> &[&'static [u8]] { + pub fn positionals(&self) -> &[&'a [u8]] { self.clap.positionals() } - pub fn remaining(&self) -> &[&'static [u8]] { + pub fn remaining(&self) -> &[&'a [u8]] { self.clap.remaining() } @@ -497,7 +497,7 @@ impl Args { pub fn parse( params: &'static [Param], opt: ParseOptions<'_>, -) -> Result, bun_core::Error> { +) -> Result, bun_core::Error> { let mut iter = args::OsIterator::init(); let exe_arg = iter.exe_arg; @@ -518,7 +518,7 @@ pub fn parse( pub fn parse_with_table( table: &'static ConvertedTable, opt: ParseOptions<'_>, -) -> Result, bun_core::Error> { +) -> Result, bun_core::Error> { let mut iter = args::OsIterator::init(); let exe_arg = iter.exe_arg; let clap = ComptimeClap::::parse_with_table( @@ -538,13 +538,13 @@ pub fn parse_with_table( /// **Cold path** — see [`parse`]; the startup hot path is [`parse_with_table`]. #[cold] #[inline(never)] -pub fn parse_ex( +pub fn parse_ex<'a, Id: 'static, I>( params: &'static [Param], iter: &mut I, opt: ParseOptions<'_>, -) -> Result, bun_core::Error> +) -> Result, bun_core::Error> where - I: args::ArgIter<'static>, + I: args::ArgIter<'a>, { ComptimeClap::::parse(params, iter, opt) } diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 3b3a32c87168..7ca9c9d0b07a 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1451,16 +1451,7 @@ unsafe fn apply_standalone_runtime_flags( /// `RunCommand` param table and return `!args.flag("--no-addons")`, or `None` /// on parse error. /// -/// Note: the Rust `bun_clap::parse_ex` port currently constrains -/// `ArgIter<'static>` (parsed values are stored by reference), which would -/// force leaking the per-call UTF-8 copies of `exec_argv`. Spec only ever -/// reads the single `--no-addons` flag from the result (per the in-tree -/// `// TODO: currently this only checks for --no-addons`), so this body scans -/// the converted argv directly with the same `stop_after_positional_at = 1` -/// short-circuit — consulting `RUN_PARAMS` so a value token following a -/// value-taking flag (`-r x`, `--title x`, …) is consumed rather than treated -/// as the first positional. Full clap routing can return when `ComptimeClap` -/// grows a borrowed-lifetime variant. +/// Currently only honours `--no-addons`. /// /// # Safety /// Each `WTFStringImpl` in `exec_argv` is a live WTF string (the C++ @@ -1468,76 +1459,27 @@ unsafe fn apply_standalone_runtime_flags( unsafe fn parse_worker_exec_argv_allow_addons( exec_argv: &[bun_core::WTFStringImpl], ) -> Option { - use crate::cli::arguments::RUN_PARAMS; - use bun_clap::Values; - - fn find_short(c: u8) -> Option<&'static bun_clap::Param> { - RUN_PARAMS.iter().find(|p| p.names.short == Some(c)) - } - - // Does `bytes` name a `RUN_PARAMS` flag whose value is supplied by the - // *next* token? `--long=val` and `OneOptional` params never pull from the - // iterator. A bare `--long` / `-s` pulls when its `takes_value` is - // `One`/`Many`. A chained-short cluster (`-br`) pulls when every prefix - // char is a `None` short and the last char is a `One`/`Many` short with - // nothing after it — `StreamingClap::chainging` calls `iter.next()` in - // exactly that case. - fn flag_consumes_next_token(bytes: &[u8]) -> bool { - if let Some(long) = bytes.strip_prefix(b"--") { - let param = RUN_PARAMS.iter().find(|p| p.names.matches_long(long)); - return matches!( - param.map(|p| p.takes_value), - Some(Values::One | Values::Many) - ); - } - if let Some(shorts) = bytes.strip_prefix(b"-") { - for (i, &c) in shorts.iter().enumerate() { - let Some(param) = find_short(c) else { - return false; - }; - match param.takes_value { - Values::None => continue, - // `StreamingClap::chainging` stops chaining at a - // `OneOptional` short without pulling from the iterator; - // remaining cluster chars are dropped. - Values::OneOptional => return false, - // `One`/`Many`: pulls from the iterator only when this is - // the last char; otherwise the rest of the cluster (or - // the text after `=`) is the inline value. - Values::One | Values::Many => return i + 1 == shorts.len(), - } - } - } - false - } + let owned: Vec<_> = exec_argv + .iter() + .filter(|a| !a.is_null()) + // SAFETY: per fn contract — each `arg` is a live `WTFStringImpl*`. + .map(|&a| unsafe { &*a }.to_owned_slice_z()) + .collect(); + let argv: Vec<&[u8]> = owned.iter().map(|s| s.as_bytes()).collect(); + + let mut iter = bun_clap::args::SliceIterator::init(&argv); + let args = bun_clap::parse_ex::( + crate::cli::arguments::RUN_PARAMS, + &mut iter, + bun_clap::ParseOptions { + diagnostic: None, + stop_after_positional_at: 1, + }, + ) + .ok()?; - let mut no_addons = false; - let mut prev_wants_value = false; - for &arg in exec_argv { - if arg.is_null() { - continue; - } - // SAFETY: per fn contract — `arg` is a live `WTFStringImpl*`. - let owned = unsafe { &*arg }.to_owned_slice_z(); - let bytes = owned.as_bytes(); - // The previous `One`/`Many` flag consumes this token as its value via - // raw `iter.next()` — regardless of a leading `-` or a literal `--`. - if core::mem::take(&mut prev_wants_value) { - continue; - } - // `stop_after_positional_at = 1` — first positional ends parsing. - // `StreamingClap::parse_next_arg` treats bare `-` and `--` as - // positionals. - if bytes.first() != Some(&b'-') || bytes == b"-" || bytes == b"--" { - break; - } - if bytes == b"--no-addons" { - no_addons = true; - } - prev_wants_value = flag_consumes_next_token(bytes); - } - // Override `allow_addons` unconditionally on successful parse. - Some(!no_addons) + // override the existing even if it was set + Some(!args.flag(b"--no-addons")) } /// `jsc.API.cron.CronJob.clearAllForVM(vm, .teardown)` — From d0ef27cd97b1dde5b5e97760b3f72a2201578672 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:37:36 +0000 Subject: [PATCH 8/8] use rodata RUN_TABLE via parse_with_table instead of parse_ex Avoids the one-time ConvertedTable::for_params mutex + leak; RUN_TABLE is already baked by comptime_table!. --- src/runtime/jsc_hooks.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 7ca9c9d0b07a..5114fb0cdf32 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1468,8 +1468,8 @@ unsafe fn parse_worker_exec_argv_allow_addons( let argv: Vec<&[u8]> = owned.iter().map(|s| s.as_bytes()).collect(); let mut iter = bun_clap::args::SliceIterator::init(&argv); - let args = bun_clap::parse_ex::( - crate::cli::arguments::RUN_PARAMS, + let args = bun_clap::ComptimeClap::::parse_with_table( + crate::cli::arguments::RUN_TABLE, &mut iter, bun_clap::ParseOptions { diagnostic: None,