run: signal every concurrent child on Ctrl-C, and make --color take effect - #744
run: signal every concurrent child on Ctrl-C, and make --color take effect#744colinhacks wants to merge 5 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Important
One of the two commands in the new --color docs section fails to parse. nub run -r --no-color build exits 2 with error: unexpected argument '--no-color' found — --no-color is only recognized by the pre-verb argv scan, never by clap.
Reviewed changes — the full diff at 15234cb, plus the surrounding signal-forwarding, color-predicate, and workspace-run code, and an empirical check of the documented flag positions against a published nub binary.
- Concurrent Ctrl-C fan-out —
ctrl_c's singleAtomicI32target becomes aMutex<Vec<i32>>multiset, so each of themin(4, cpus)concurrent-rmembers registers and removes its own group instead of evicting its siblings. untrack_childtakes a pid — every call site now passes the pid it tracked, so the first member to finish no longer disarms forwarding for the rest.--colorbecomes load-bearing — a newCOLOR_MODEglobal is recorded from both the pre-verb scan and the parsed clapCli, andbuild_script_commandexportsFORCE_COLOR=1/0to script children foralways/never.- One color predicate —
color_enabledreplaces the open-codedis_terminal() || FORCE_COLOR presentchecks in the stream prefix,help_bold, andpm_engine::scope_warning_uses_dim, adding the missingNO_COLORconsultation and readingFORCE_COLOR=0as off. --reporter-hide-prefixnarrowed — Nub's ownDone/exit N/error:status lines route through a newformat_status_prefixthat keeps the<dir> <script>:label.- Tests and docs — three signal tests (fan-out, sibling survival, the rewritten evict-vs-add test), five color/reporter integration tests with a
color-probefixture script, and new--reporter-hide-prefix/--colorsections inrun.mdx.
ℹ️ Three doc comments still describe the predicate this PR replaced
color_enabled is now the single answer for every ANSI decision, but three comments around its callers still describe the old two-term check and omit the explicit-flag layer that now outranks the environment. The one in pm_engine/mod.rs sits two lines above the function this PR changed.
Technical details
# Stale color-predicate doc comments
## Affected sites
- `crates/nub-cli/src/pm_engine/mod.rs:1492` — `emit_scope_warnings`'s doc says dim is chosen when "stderr is a terminal (or `FORCE_COLOR` set) and `NO_COLOR` is unset". It now also depends on `--color`/`--no-color`, and `FORCE_COLOR=0` now reads as off. Directly above `scope_warning_uses_dim`, whose body this PR rewrote.
- `crates/nub-cli/src/cli.rs:10360` — `emit_shim_version_line` describes itself as "reusing the PM engine's TTY/`FORCE_COLOR`/`NO_COLOR` predicate"; the engine now delegates to `cli::color_enabled`.
- `crates/nub-cli/src/pm_engine/use_nub.rs:1044` — `warn_phantom_dependencies` says "dim-styled when stderr is a terminal and `NO_COLOR` is unset (same gate as the rest of pm_engine)".
## Required outcome
- Each comment describes the four-tier precedence `color_enabled` actually implements, or defers to it by name instead of restating it.ℹ️ Nitpicks
crates/nub-cli/src/cli.rs:6369andcrates/nub-core/src/node/spawn.rs:1390both?out ofchild.wait()before reachinguntrack_child. That shape predates this PR, but its consequence changed: the old single slot healed itself on the nexttrack(), whereas theVeckeeps the stale-pidfor the life of the process and a later signal wouldkillwhatever group inherits it.Child::wait()failing is close to impossible, so this is only worth the cost if an RAII guard over the track/untrack pair falls out cheaply.- The
FORCE_COLOR=1/FORCE_COLOR=0mapping is attributed to a local measurement of pnpm 10.15.1. pnpm's CLI docs don't list--colorat all and its code search is sign-in gated, so nothing external corroborates it — worth a source link in the comment atcli.rs:5704if one exists.
Claude Opus | 𝕏
…table Two findings from review on #744. --no-color was only caught by the pre-subcommand argv scan, which stops at the first verb, so `nub run -r --no-color build` exited 2 with "unexpected argument" while the pre-verb spelling worked. pnpm accepts it in both positions (measured on 10.15.1). Declare it to clap as a global as well, and list it in `nub --help`, where it appeared nowhere. A flag after the script name still forwards to the script, so the three-position rule holds. color_enabled read an empty FORCE_COLOR as OFF. Node's getColorDepth lists `case ''` alongside '1' and 'true', so empty is its shortest spelling of ON — and the point of this predicate is that nub agrees with the children it hands the variable to, which made that arm invert the one convention it existed to match. Follow Node's table exactly: '', '1', 'true', '2' and '3' enable; everything else, 'false' and unrecognized values included, is monochrome. Checked against a live getColorDepth across all eight values.
There was a problem hiding this comment.
ℹ️ No blocking issues in the new commit — both prior findings are properly fixed, with one observation and two nits inline.
Reviewed changes — the delta since the prior pullfrog review at 15234cb, plus the surrounding color-resolution code, the clap positional-splitting helpers, and Node's lib/internal/tty.js as the authority for the new value table. The signal-forwarding work from the first commit is unchanged and was not re-reviewed.
- Made
--no-colora real clap arg — declared as aglobal,conflicts_with = "color"flag onCli, so the post-verb spellingnub run -r --no-color buildparses instead of exiting 2, and the docs example is no longer broken. Being a boolean, it needs novalue_consuming_flagsentry, sosplit_subcommand_argvstill binds the script positional correctly. - Routed it through
dispatch_subcommand—if cli.no_color { Never } else if cli.color != Auto { cli.color }, so the later spelling wins while clap'sautodefault still can't clobber a pre-verb--color. - Aligned the
FORCE_COLORarm with Node's table —'','1','true','2'and'3'enable color; everything else,'0'and'false'included, disables it. I checked this againstnodejs/node'slib/internal/tty.jsonmain: the value table matches exactly, and Node'sNO_COLORcheck is likewise "defined and non-empty", so the new comments are accurate. - Documented the one deliberate divergence — when both
FORCE_COLORandNO_COLORare set, Node letsFORCE_COLORwin and warns; Nub letsNO_COLORwin. - Added two integration tests —
--no-colorin both flag positions, and the five-valueFORCE_COLORtable. - Listed
--no-colorin the hand-rolled--help.
ℹ️ Nitpicks
- Post-verb
--color=<when>still has no end-to-end coverage.color_always_exports_force_color_to_the_scriptuses the pre-verb spelling, so theelse if cli.color != ColorWhen::Autobranch atcrates/nub-cli/src/cli.rs:2799— restructured by this commit — is only exercised through clap's own parse test. That's the same shape as the defect this PR fixes (a flag that parsed into a field nothing read), and it costs one extra row in the existing--no-colorposition loop.
Claude Opus | 𝕏
Review follow-up on #744. Nub resolves NO_COLOR over FORCE_COLOR; Node resolves them the other way. With both set the two answers disagreed inside a single run: nub's label went plain while the child colorized, so a workspace run emitted colored lines wrapped in uncolored labels. Measured before the fix — the child's getColorDepth returned 4 under `NO_COLOR=1 FORCE_COLOR=1`. In auto mode the launcher now exports FORCE_COLOR=0 for exactly that contradictory pair, pinning the child to nub's answer. Auto still forces nothing when neither variable is set or only one of them is, which is every ordinary invocation; the two added tests cover the split case and that control. The precedence itself is unchanged — NO_COLOR still wins, keeping the promise `nub --help` prints. Also factor Node's FORCE_COLOR value table into one helper now that both the predicate and the launcher read it, so they cannot drift, and clear NO_COLOR in the --no-color position test: it set FORCE_COLOR=1 without clearing NO_COLOR, so an ambient NO_COLOR would have satisfied its "color is off" assertion whether or not the flag did anything.
There was a problem hiding this comment.
ℹ️ No blocking issues in the new commit — both prior findings are properly fixed, with one scoping observation inline.
Reviewed changes — the delta since the prior pullfrog review at 5966656, plus every call site of build_script_command and the StreamMode each one passes. The signal-forwarding work and the --reporter-hide-prefix change are unchanged and were not re-reviewed.
- Extracted the
FORCE_COLORvalue table into a shared helper —force_color_enables(&OsStr)is now called by bothcolor_enabledand the script launcher, so the value Nub reads and the value it hands a child can't drift. The table itself is unchanged. - Pinned the child's
FORCE_COLORfor the one contradictory pair — inauto, whenFORCE_COLORis set to an enabling value andNO_COLORis set,build_script_commandexportsFORCE_COLOR=0so Nub's plain label and the child's lines agree. The predicate reduces to exactly that pair:FORCE_COLORalone can never trigger it, becausecolor_enabledreads the same value and agrees. - Named the consequence in the divergence note —
color_enabled's doc comment now says what theNO_COLOR-wins order would otherwise do to a run, and points at the arm that closes it. - Added the test and its control —
contradictory_color_env_does_not_split_nub_from_its_childasserts the child receivesFC=0, andauto_still_forces_nothing_when_only_no_color_is_setassertsFC=unsetwith only one variable set. Reverting the arm turns the first red, so it isn't passing for the wrong reason. - Tightened the
--no-colorposition test —.env_remove("NO_COLOR")added, so an ambientNO_COLORcan no longer satisfy the assertion on the flag's behalf.
ℹ️ Nitpicks
site/content/docs/runner/run.mdx:382still says "Without the flag Nub forces nothing either way, and the usual environment signals decide". That is now false for the one case this commit added — with bothNO_COLORandFORCE_COLORset, Nub decides for the child rather than letting the environment signals reach it. Half a sentence covers it.
Claude Opus | 𝕏
Review follow-up on #744, fixing a regression the previous commit introduced. build_script_command is shared by every stream mode, and the auto-mode FORCE_COLOR=0 export sat above the StreamMode::Prefixed branch, so it also fired for inherited-stdio runs: plain `nub run <script>`, `-w`, and `-r --sequential`. Nothing is prefixed on those paths, so the contradiction the export exists to repair cannot arise there — and overriding the pair diverged from everyone else. Measured under `NO_COLOR=1 FORCE_COLOR=1`: pnpm, npm and a bare shell all hand the script FORCE_COLOR=1, while nub had started handing it 0. Gate the arm on StreamMode::Prefixed. A prefixed run still repairs the split; an un-prefixed one passes both variables through untouched. An explicit --color/--no-color is unaffected and still reaches the child in either mode, which is what the user asked for. Adds the missing row: a test driving the contradictory pair through the inherited path. Also corrects the docs, which claimed nub forces nothing without a flag — true again for every un-prefixed run, and now stated precisely for the prefixed one.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the delta since the prior pullfrog review at d8dfd79, plus StreamMode's full variant set, both build_script_command call sites, and the workspace-detection walk that decides which one the new test reaches.
- Scoped the
autoFORCE_COLOR=0export to the prefixed path — the arm is nowColorWhen::Auto if matches!(stream, StreamMode::Prefixed)with a bareColorWhen::Auto => {}fall-through.StreamModehas exactly two variants, so every piped run still gets the contradictory pair repaired and every inherited-stdio run hands the script the environment it was given, matching pnpm, npm and a bare shell. - Rewrote the rationale in place — the comment now says why the exception is confined to the path where Nub actually wraps the child's lines, rather than describing a repair it was applying where nothing needed repairing.
- Pinned the
Inheritpath with a test —an_unprefixed_run_passes_the_color_env_through_untouchedrunsnub run color-probefrompackages/corewith both variables set and asserts the child seesFC=1. I traced the path it exercises:detect_project_walkroots the project at the member directory, nothing infers an implicit-r, so it lands inrun_single_script→spawn_script, the onlyStreamMode::Inheritcall site. Deleting the guard makes the assertion readFC=0, so the test is load-bearing rather than decorative. - Closed the docs gap —
run.mdxnow states what happens when both variables are set, and that an unprefixed run passes them through, which is the qualification the earlier "Nub forces nothing either way" sentence was missing.
Claude Opus | 𝕏
…ffect A recursive run executes members on concurrent worker threads — concurrency defaults to min(4, cpus), so this is the default shape, not only --parallel. Four defects in that shape, all reported in #685. Ctrl-C reached exactly one child. The signal forwarder held a single AtomicI32 target, so each spawn overwrote the last and untrack_child() zeroed the slot when the first member finished. Interrupting a multi-server dev run signalled one script (which one was a race), orphaned the rest holding their ports, and left nub blocked waiting on children it could no longer signal. The target is now a set: each child adds its own group and removes only its own on exit. group_on_spawn is untouched, so the own-process-group behavior #26, #27 and #463 rely on still holds — pnpm's shared-group model is deliberately not copied. --color=always did nothing at all. Cli::color had no readers, and the pre-subcommand scan stripped the token before clap could see it, so a flag listed in `nub --help` was inert. It now records the choice, colors nub's own output, and exports FORCE_COLOR to each script: 1 for --color=always, 0 for --no-color, matching pnpm 10.15.1. Default auto still forces nothing, so prefixed output is unchanged unless asked. Two predicate bugs sat in the same place. NO_COLOR was never consulted on the stream-prefix path despite --help promising it, and FORCE_COLOR=0 read as "present, therefore on" — which would have inverted --no-color for a nested nub. Both now resolve through one color_enabled predicate. --reporter-hide-prefix stripped the label from nub's own Done/exit/error line too, so a run ended in identical unattributable `Done`s. pnpm keeps the label on its status line; nub now does the same. The flag still hides the child's per-line prefix, which is what CI annotation matchers need. Closes #685
…table Two findings from review on #744. --no-color was only caught by the pre-subcommand argv scan, which stops at the first verb, so `nub run -r --no-color build` exited 2 with "unexpected argument" while the pre-verb spelling worked. pnpm accepts it in both positions (measured on 10.15.1). Declare it to clap as a global as well, and list it in `nub --help`, where it appeared nowhere. A flag after the script name still forwards to the script, so the three-position rule holds. color_enabled read an empty FORCE_COLOR as OFF. Node's getColorDepth lists `case ''` alongside '1' and 'true', so empty is its shortest spelling of ON — and the point of this predicate is that nub agrees with the children it hands the variable to, which made that arm invert the one convention it existed to match. Follow Node's table exactly: '', '1', 'true', '2' and '3' enable; everything else, 'false' and unrecognized values included, is monochrome. Checked against a live getColorDepth across all eight values.
Review follow-up on #744. Nub resolves NO_COLOR over FORCE_COLOR; Node resolves them the other way. With both set the two answers disagreed inside a single run: nub's label went plain while the child colorized, so a workspace run emitted colored lines wrapped in uncolored labels. Measured before the fix — the child's getColorDepth returned 4 under `NO_COLOR=1 FORCE_COLOR=1`. In auto mode the launcher now exports FORCE_COLOR=0 for exactly that contradictory pair, pinning the child to nub's answer. Auto still forces nothing when neither variable is set or only one of them is, which is every ordinary invocation; the two added tests cover the split case and that control. The precedence itself is unchanged — NO_COLOR still wins, keeping the promise `nub --help` prints. Also factor Node's FORCE_COLOR value table into one helper now that both the predicate and the launcher read it, so they cannot drift, and clear NO_COLOR in the --no-color position test: it set FORCE_COLOR=1 without clearing NO_COLOR, so an ambient NO_COLOR would have satisfied its "color is off" assertion whether or not the flag did anything.
Review follow-up on #744, fixing a regression the previous commit introduced. build_script_command is shared by every stream mode, and the auto-mode FORCE_COLOR=0 export sat above the StreamMode::Prefixed branch, so it also fired for inherited-stdio runs: plain `nub run <script>`, `-w`, and `-r --sequential`. Nothing is prefixed on those paths, so the contradiction the export exists to repair cannot arise there — and overriding the pair diverged from everyone else. Measured under `NO_COLOR=1 FORCE_COLOR=1`: pnpm, npm and a bare shell all hand the script FORCE_COLOR=1, while nub had started handing it 0. Gate the arm on StreamMode::Prefixed. A prefixed run still repairs the split; an un-prefixed one passes both variables through untouched. An explicit --color/--no-color is unaffected and still reaches the child in either mode, which is what the user asked for. Adds the missing row: a test driving the contradictory pair through the inherited path. Also corrects the docs, which claimed nub forces nothing without a flag — true again for every un-prefixed run, and now stated precisely for the prefixed one.
The --no-color doc comment quoted the phrase clap prints when it refuses an argument, to explain what the flag fixes. clap renders a doc comment into --help, and the arg is global, so that sentence appeared in EVERY subcommand's help page. cli_grammar_parity decides whether a form was refused by spawning `nub <form> --help` and grepping the output for exactly that wording. With the phrase now sitting in every help page, all three of its suites read every documented pnpm form as rejected: `nub install -D`, `-r`, `--filter`, the whole table. Six Test jobs went red across both platforms. Demote the rationale to a plain comment, which clap never renders, and leave a one-line doc comment as the help text. No behavior change; the grammar suites pass again.
14c164e to
d0d7b7b
Compare
There was a problem hiding this comment.
ℹ️ No new issues — the fix is correct and minimal. One observation on the constraint it works around.
Reviewed changes — the delta since the prior pullfrog review at 14c164e, plus cli_grammar_parity's rejection detector and the --help surface it reads. The signal fan-out, --color resolution and --reporter-hide-prefix work is unchanged and was not re-reviewed.
- Demoted the
--no-colorrationale to a plain comment — the clap-versus-argv-scan explanation onCli::no_coloris now//rather than///, so clap stops rendering it aslong_help. The user-facing one-liner stays a doc comment, so--helpstill describes the flag. - Recorded the coupling in place — the new comment states why the text cannot be a doc comment, which is the part a future edit would otherwise re-break.
I traced the mechanism rather than taking the commit message for it. clap_rejected spawns nub <form> --help and greps the combined stdout and stderr for unexpected argument / unrecognized (crates/nub-cli/tests/cli_grammar_parity.rs:78-84); clap derive turns a multi-paragraph doc comment into long_help, and a global = true arg's long_help lands in every subcommand's help. So the removed paragraph did make every row in that table report a rejection. That failure was loud rather than silent — rejected == true pushes a row into failures — so nothing was passing vacuously in the meantime. I also checked that no other global-arg doc comment in cli.rs carries either marker.
ℹ️ The parity detector still reads help text as if it were parser output
clap_rejected decides "the parser refused this form" from a substring of stdout and stderr, but clap prints help to stdout and parse errors to stderr. Nothing in --help prose is parser output, so scanning it can only produce false rejections — which is exactly what happened here, and the fix landed on the prose rather than the detector. The workaround holds today, but it leaves a standing rule that no --help text may contain two ordinary English phrases.
Technical details
# `cli_grammar_parity`'s reject markers are matched against help output
## Affected sites
- `crates/nub-cli/tests/cli_grammar_parity.rs:78-84` — `combined` concatenates stdout and
stderr, then `rejected` is `combined.contains("unexpected argument") ||
combined.contains("unrecognized")`. Help text printed to stdout is scanned as though it
were a clap error.
- `crates/nub-cli/src/cli.rs:749-753` — the new comment exists only to keep help prose out
of the detector's way. Any future `global` arg doc comment describing a rejection
reintroduces the same red.
## Required outcome
- A row fails only when clap actually refused the grammar, so help prose cannot influence
the verdict — and `cli.rs` no longer has to avoid two English phrases.
## Suggested approach (optional)
- Match the markers against `out.stderr` alone. Clap writes `--help` output to stdout and
`Error::print` to stderr, so the two streams already separate parser verdicts from prose;
`combined` can stay in the returned tuple for the failure dump.
- Worth confirming empirically on one known-rejecting form before relying on it — the same
suite is the natural place to pin it, since a row that stops detecting rejections would
otherwise fail open.
## Open questions for the human
- Is this worth touching in this PR at all, given the comment now documents the constraint?
It is orthogonal to #685 and would be a clean standalone change.Claude Opus | 𝕏

Fixes #685 plus four adjacent defects found while verifying it. Measured against pnpm 10.15.1. Recursive concurrency defaults to
min(4, cpus), so this hits plainnub run -r, not only--parallel.AtomicI32target each spawn overwrote, zeroed by the first exit. Siblings were orphaned and nub hung. Now a set;group_on_spawnuntouched, so SIGINT emitted twice on Ctrl+C #26/Does not work with Nx TUI #27/nub run orphans its workload on SIGKILL (Playwright webServer default teardown) — proposal: PR_SET_PDEATHSIG on the script child #463 hold.--color=alwayswas inert. Now exportsFORCE_COLOR=1to scripts and--no-colorexports0, matching pnpm;--no-coloralso parses after the verb, as pnpm allows.NO_COLORwas ignored, andFORCE_COLORnow follows Node's value table (''andfalsewere both wrong).--reporter-hide-prefixstripped nub's ownDonelabel; pnpm keeps it.Reverting the signal fix turns three tests red. 12 new tests.