Skip to content

run: signal every concurrent child on Ctrl-C, and make --color take effect - #744

Open
colinhacks wants to merge 5 commits into
mainfrom
fix-685-concurrent-run
Open

run: signal every concurrent child on Ctrl-C, and make --color take effect#744
colinhacks wants to merge 5 commits into
mainfrom
fix-685-concurrent-run

Conversation

@colinhacks

@colinhacks colinhacks commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

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 plain nub run -r, not only --parallel.

Reverting the signal fix turns three tests red. 12 new tests.

Copilot AI lite review requested due to automatic review settings August 15, 2026 18:45
@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview Aug 16, 2026 12:56am

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-outctrl_c's single AtomicI32 target becomes a Mutex<Vec<i32>> multiset, so each of the min(4, cpus) concurrent -r members registers and removes its own group instead of evicting its siblings.
  • untrack_child takes a pid — every call site now passes the pid it tracked, so the first member to finish no longer disarms forwarding for the rest.
  • --color becomes load-bearing — a new COLOR_MODE global is recorded from both the pre-verb scan and the parsed clap Cli, and build_script_command exports FORCE_COLOR=1/0 to script children for always/never.
  • One color predicatecolor_enabled replaces the open-coded is_terminal() || FORCE_COLOR present checks in the stream prefix, help_bold, and pm_engine::scope_warning_uses_dim, adding the missing NO_COLOR consultation and reading FORCE_COLOR=0 as off.
  • --reporter-hide-prefix narrowed — Nub's own Done / exit N / error: status lines route through a new format_status_prefix that 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-probe fixture script, and new --reporter-hide-prefix / --color sections in run.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:6369 and crates/nub-core/src/node/spawn.rs:1390 both ? out of child.wait() before reaching untrack_child. That shape predates this PR, but its consequence changed: the old single slot healed itself on the next track(), whereas the Vec keeps the stale -pid for the life of the process and a later signal would kill whatever 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=0 mapping is attributed to a local measurement of pnpm 10.15.1. pnpm's CLI docs don't list --color at all and its code search is sign-in gated, so nothing external corroborates it — worth a source link in the comment at cli.rs:5704 if one exists.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread site/content/docs/runner/run.mdx
Comment thread crates/nub-cli/src/cli.rs Outdated
colinhacks added a commit that referenced this pull request Aug 15, 2026
…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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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-color a real clap arg — declared as a global, conflicts_with = "color" flag on Cli, so the post-verb spelling nub run -r --no-color build parses instead of exiting 2, and the docs example is no longer broken. Being a boolean, it needs no value_consuming_flags entry, so split_subcommand_argv still binds the script positional correctly.
  • Routed it through dispatch_subcommandif cli.no_color { Never } else if cli.color != Auto { cli.color }, so the later spelling wins while clap's auto default still can't clobber a pre-verb --color.
  • Aligned the FORCE_COLOR arm with Node's table'', '1', 'true', '2' and '3' enable color; everything else, '0' and 'false' included, disables it. I checked this against nodejs/node's lib/internal/tty.js on main: the value table matches exactly, and Node's NO_COLOR check is likewise "defined and non-empty", so the new comments are accurate.
  • Documented the one deliberate divergence — when both FORCE_COLOR and NO_COLOR are set, Node lets FORCE_COLOR win and warns; Nub lets NO_COLOR win.
  • Added two integration tests--no-color in both flag positions, and the five-value FORCE_COLOR table.
  • Listed --no-color in the hand-rolled --help.

ℹ️ Nitpicks

  • Post-verb --color=<when> still has no end-to-end coverage. color_always_exports_force_color_to_the_script uses the pre-verb spelling, so the else if cli.color != ColorWhen::Auto branch at crates/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-color position loop.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread crates/nub-cli/src/cli.rs
Comment thread crates/nub-cli/tests/integration.rs
colinhacks added a commit that referenced this pull request Aug 15, 2026
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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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_COLOR value table into a shared helperforce_color_enables(&OsStr) is now called by both color_enabled and 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_COLOR for the one contradictory pair — in auto, when FORCE_COLOR is set to an enabling value and NO_COLOR is set, build_script_command exports FORCE_COLOR=0 so Nub's plain label and the child's lines agree. The predicate reduces to exactly that pair: FORCE_COLOR alone can never trigger it, because color_enabled reads the same value and agrees.
  • Named the consequence in the divergence notecolor_enabled's doc comment now says what the NO_COLOR-wins order would otherwise do to a run, and points at the arm that closes it.
  • Added the test and its controlcontradictory_color_env_does_not_split_nub_from_its_child asserts the child receives FC=0, and auto_still_forces_nothing_when_only_no_color_is_set asserts FC=unset with only one variable set. Reverting the arm turns the first red, so it isn't passing for the wrong reason.
  • Tightened the --no-color position test.env_remove("NO_COLOR") added, so an ambient NO_COLOR can no longer satisfy the assertion on the flag's behalf.

ℹ️ Nitpicks

  • site/content/docs/runner/run.mdx:382 still 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 both NO_COLOR and FORCE_COLOR set, Nub decides for the child rather than letting the environment signals reach it. Half a sentence covers it.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread crates/nub-cli/src/cli.rs Outdated
colinhacks added a commit that referenced this pull request Aug 15, 2026
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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 auto FORCE_COLOR=0 export to the prefixed path — the arm is now ColorWhen::Auto if matches!(stream, StreamMode::Prefixed) with a bare ColorWhen::Auto => {} fall-through. StreamMode has 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 Inherit path with a testan_unprefixed_run_passes_the_color_env_through_untouched runs nub run color-probe from packages/core with both variables set and asserts the child sees FC=1. I traced the path it exercises: detect_project_walk roots the project at the member directory, nothing infers an implicit -r, so it lands in run_single_scriptspawn_script, the only StreamMode::Inherit call site. Deleting the guard makes the assertion read FC=0, so the test is load-bearing rather than decorative.
  • Closed the docs gaprun.mdx now 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.

Pullfrog  | View workflow run | Using 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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-color rationale to a plain comment — the clap-versus-argv-scan explanation on Cli::no_color is now // rather than ///, so clap stops rendering it as long_help. The user-facing one-liner stays a doc comment, so --help still 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.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Fetaure Request] Improve concurrent / parallel run

2 participants