feat: v1.1.0 Linux support (cgroup.kill + process-group fallback) - #1
Merged
Merged
Conversation
First half of the v1.1.0 Linux port. Mirrors the Windows wrapper shape (Find-ClaudeExe + claude-jobbed.ps1) but uses POSIX kernel primitives instead of Win32 Job Objects. tools/linux/find-claude.sh -- 9-probe path algorithm. Pure bash, sourceable + executable. Probe order: PATH > npm prefix > Homebrew (arm64 then x86_64) > nvm (highest version) > fnm > asdf > volta > yarn global. Returns 127 cleanly if no candidate found. tools/linux/claude-jobbed.sh -- two-tier wrapper: STRONG path (kernel >= 5.14): systemd-run --user --scope. Transient unit runs in our shell (stdio stays wired, exit code propagates), and on wrapper death systemd reaps the entire scope via cgroup.kill. Closest POSIX equivalent of the Win32 KILL_ON_JOB_CLOSE primitive. Survives SIGKILL of the wrapper AND survives wrapper-process panic. FALLBACK path: setpgid + trap on EXIT/INT/TERM/HUP. Works on no-systemd containers, older kernels, WSL1, minimal Alpine. Documented limitation: bash traps don't fire on SIGKILL of the wrapper itself -- the README will say so honestly. CLAUDE_JOBBED_FORCE_FALLBACK=1 escape hatch lets us exercise the fallback code on a CI runner that has systemd available -- without it, the fallback is dead code from the test perspective. Verification deferred to GitHub Actions ubuntu-latest runner (coming in a later commit on this branch). Cannot exec bash on Windows dev machine.
Three test files covering the find-claude probe and the wrapper's two reaper paths. Mirrors the Windows tests/test-*.ps1 structure but in bats-core. tests/linux/test-find-claude.bats (8 tests) Sandboxes PATH + HOME so probes only hit paths we control. Covers PATH > npm prefix > nvm (highest version wins) > fnm > yarn global, the priority-order invariant (PATH wins over npm), the 127-when-not- found contract, and the source-mode contract (defines find_claude function with no script-side side effect). Probes 3-4 (Homebrew /opt/homebrew, /usr/local) require absolute writable system paths and are honestly skipped here -- they land in the v1.2.0 macOS CI runner. tests/linux/test-pgid-cleanup.bats (3 tests) Forces fallback path via CLAUDE_JOBBED_FORCE_FALLBACK=1. Spawns a fake claude that writes its grandchild PID to a file, kills the wrapper with SIGTERM, polls (3s budget) for the grandchild to die. Plus exit-code propagation (claude exits 42 -> wrapper exits 42) and verbatim arg forwarding. tests/linux/test-cgroup-kill.bats (1 test, the load-bearing one) This is the parity test against Win32 KILL_ON_JOB_CLOSE. Lets the wrapper take the strong (systemd-run --scope) path, then SIGKILL's the wrapper. Bash traps don't fire on -9, so only kernel-enforced cleanup via cgroup.kill can satisfy this. Skips cleanly when systemd-run is missing, --user systemd isn't active, or kernel is below 5.14 -- and prints WHY in the skip message so a CI log reader can tell "skip = environment, not silently broken."
Top-level install.sh -- the user-facing entry point on POSIX systems.
Mirrors install-reap.ps1 (PowerShell side) in spirit but uses bash
+ awk + grep, no Node/Python dependency.
Behavior:
* Detects OS via uname -s. On Linux, also reads kernel version
and prints the guarantee tier (STRONG on >= 5.14, MEDIUM below).
* Refuses cleanly on Darwin -- macOS support is the v1.2.0
milestone, not v1.1.0. Pointer to the milestone in the error.
* Refuses on unknown OS, points Windows users at install-reap.ps1.
* Prints the path that will be wrapped + asks for [y/N] confirm.
--yes / -y skips the prompt (required for CI).
* Injects a marker-bracketed shell function block:
# >>> claude-code-structured-concurrency >>>
claude() { command "$wrapper" "$@"; }
# <<< claude-code-structured-concurrency <<<
into ~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish (each only
if the file already exists -- we don't create rc files).
* Idempotent: re-running detects the marker and skips by default.
--force removes the old block first via awk in-place rewrite,
then injects fresh.
* --uninstall reverses the inject; safe to run when nothing is
installed (no-op).
* Unknown flag exits 2 (so CI catches typos).
tests/linux/test-installer.bats (6 tests)
Sandboxes HOME so we never touch the runner's real rc files.
Covers: --yes inject, idempotent re-run preserves count, --force
prevents duplicate-block stacking (marker count stays at 2 per
file, not 4), --uninstall removes cleanly, --uninstall is no-op
when nothing was installed, unknown flag exits 2.
First CI workflow in this repo. Closes a Wave A gap: the existing
PowerShell tests had zero automated verification on push -- the
only signal was whoever next ran tests/test-*.ps1 locally. This
workflow runs them on every push to main / linux-port / macos-port
/ v* tags + on PRs to main.
ubuntu-latest job:
* apt install bats-core
* Pre-flight: log kernel + bats + systemd-run availability so a
failed run is debuggable from log alone.
* loginctl enable-linger so systemctl --user comes up. Without
this the cgroup-kill test would always SKIP on the runner --
we want it to actually exercise the strong path on every push.
If linger fails or default.target doesn't activate within 5s,
the test still skips cleanly with a printed reason. Never
silently passes.
* bats --print-output-on-failure tests/linux/ (so failure logs
include the test's stdout/stderr instead of just a TAP line).
windows-latest job:
* Iterates Get-ChildItem tests\test-*.ps1 and exits on first
non-zero. Each test file already self-reports pass/fail; this
just chains them. No special setup needed -- pwsh ships with
the runner.
Branch trigger list includes "linux-port" and "macos-port" so
feature-branch pushes also CI without needing a draft PR.
Updates the README to reflect that v1.1.0 ships Linux alongside the
existing Windows surface. The framing stays Windows-first (it shipped
first, the demo is Windows, the architecture SVG is Windows) but Linux
is now a peer install path, not a footnote.
Changes:
* Headline: Win32 Job Object on Windows + cgroup.kill (5.14+) /
process-group fallback on Linux. macOS = v1.2.0 milestone, said
so explicitly.
* Platform badge: Windows 10+ | Linux. Tests badge: 36+1 pwsh +
18 bats (was 22+1 — undercount, plus new Linux tests).
* NEW "Guarantee matrix" section near the top: Windows STRONG,
Linux 5.14+ STRONG, Linux <5.14 MEDIUM, macOS WEAK (v1.2.0).
Names the SIGKILL-survival property explicitly per platform so
no one has to read DESIGN.md to know what they're getting.
* Requirements split into Windows side / Linux side. Linux
requirements are honest about the optional-but-recommended
kernel 5.14 + systemd-run path.
* Install split into "Windows" (existing PS one-liner, unchanged)
and "Linux" (new bash one-liner via install.sh, with --yes /
--force / --uninstall doc'd).
* Components split into Windows table (existing 3 tools) and
Linux table (new: find-claude.sh + claude-jobbed.sh).
* Tests section: 4 PowerShell suites (added test-spawn-plan.ps1
which was missing) + 4 bats suites with per-suite coverage,
plus a note about the loginctl enable-linger CI quirk.
* "How it works" gets a Linux subsection that walks through the
systemd-run --scope strong path AND the set -m + trap fallback,
with the SIGKILL-escapes-trap gap called out as the documented
MEDIUM-tier limitation.
* Fixed misclaim in "What this does not do": the prior version
said macOS "already has OS-level reapers (equivalent semantics
on macOS)" -- this is wrong, and is the entire reason Wave D
(v1.2.0) is a separate milestone. Replaced with honest text:
macOS has no SIGKILL-surviving primitive, v1.2.0 will ship the
process-group + atexit wrapper with explicit honesty in the
install banner.
Untouched: demo video, architecture SVG, auditable section
(file-write surface), config profiles, license, reading list. All
correctly describe the Windows code as-is.
Two install.sh bugs caught by the first CI run on linux-port (PR #1). 1) `awk: fatal: cannot use gawk builtin 'close' as variable name` Both awk blocks (--force overwrite + --uninstall removal) were passing `-v close="..."` to declare a variable. gawk reserves `close` because of its `close()` function. Ubuntu CI ships gawk; mawk and busybox awk don't reserve the name, which is why this slipped past local linting. Fix: rename to `marker_close` (and `marker_open` for symmetry). `open` is technically not a gawk builtin, but the consistent naming makes the intent clearer and protects against any future awk implementation that might claim it. Failed in CI: tests 13 + 14 (--force overwrite + --uninstall clean removal). 2) Inverted kernel-version compare against 5.14 Old: `printf '%s\n5.14\n' "$kernel" | sort -V -C` This succeeds when the input is ALREADY sorted ascending. With "kernel\n5.14" as input, it succeeds iff kernel <= 5.14 -- the exact OPPOSITE of what we want. CI ran on kernel 6.17 and reported "MEDIUM (kernel 6.17 below 5.14 floor)" -- proving the bug. Without this fix the installer would lie to every Linux user on a modern distro about which guarantee tier they're getting. Fix: swap printf order to `'5.14\n%s\n'`. Now the comparison succeeds iff kernel >= 5.14 (5.14 listed first, then kernel, ascending order requires kernel >= 5.14). Same logic bug present in tests/linux/test-cgroup-kill.bats -- fix lands in the next commit alongside the teardown hardening. Verified: ran the new sort-V-C form against {5.10, 5.14, 6.17} mentally and on a sample (sort -V -C exits 0 for "5.14\n5.14" because equal counts as sorted, which is the right behavior at the boundary).
Three test-side fixes from the first CI run on linux-port (PR #1). 1) Same inverted version compare as install.sh (just fixed in 1e34bae) tests/linux/test-cgroup-kill.bats line 31 had the same `printf '%s\n5.14\n' | sort -V -C` bug. On the ubuntu-latest runner (kernel 6.17), it short-circuited the strong-path test into a skip with the literal-impossible message "kernel 6.17 below 5.14 -- cgroup.kill unavailable". With the fix, the cgroup-kill test will actually exercise the strong path on CI (linger is enabled in the workflow), giving us the load-bearing parity check against Win32 KILL_ON_JOB_CLOSE. 2) teardown() crashed when setup() skip()'d early When the test skipped (linger fail / no systemd-run / kernel floor), SANDBOX was unset because setup() returned before reaching `mktemp -d`. teardown() then did `rm -rf "$SANDBOX"` which became `rm -rf ""` and exited 127 ("No such file or directory"), converting a clean `# skip` into `not ok` and killing the suite's exit code. Fix: guard the cleanup with `${SANDBOX:-}` and `${ORIG_PATH:-}` checks, so a skipped setup leaves nothing for teardown to do. 3) BW01 warning on test-find-claude.bats:40 bats-core warns when `run` captures a 127 exit because that typically means the script under test couldn't even be exec'd. Here 127 is the contract -- it's what find-claude.sh returns when no claude binary is found anywhere. Use `run -127` to tell bats this is expected. After this CI should be green: - test 13 (--force overwrite) and 14 (--uninstall clean) pass once gawk-shadowing fix from 1e34bae lands - test 1 (cgroup-kill strong path) actually runs and passes instead of skip-failing - 0 BW01 warnings in the CI log
Strong-path scope cleanup was not actually firing on SIGKILL of the wrapper -- the documented headline guarantee was broken. Diagnosis from CI run 25652620419 (test-cgroup-kill.bats): FAIL: grandchild PID 2883 survived wrapper SIGKILL (cgroup.kill did not reap) systemd-run --user --scope is a CONTROLLER, not a parent. From systemd.scope(5): "When the last process leaves the scope, systemd cleans the scope up." Scope lifetime tracks DESCENDANT lifetime, not controller lifetime, so SIGKILL of the wrapper just kills systemd-run; the scope stays alive with claude + grandchildren intact. Bash traps don't fire on SIGKILL either, so we cannot tear the scope down from in-process. We need an external supervisor. Fix: spawn a backgrounded subshell watchdog before invoking systemd-run. The watchdog polls our parent identity via stat -c %Y /proc/$$ -- the mtime of the procfs entry equals process start time, which is robust to PID reuse (a recycled $$ would have a different start time). When the watchdog observes the parent vanish OR the PID reassigned to a different process, it runs `systemctl --user stop $unit` which triggers cgroup.kill on every descendant -- delivering the SIGKILL-survival guarantee the README documents and the bats test exercises. Also drop `exec` -- we need bash to stay alive to manage the watchdog process group and run cleanup on graceful exit. Graceful path: trap on EXIT/INT/TERM/HUP stops the scope ourselves (no waiting on the 200ms watchdog poll) and kills the watchdog so it doesn't double-fire a no-op stop after we've exited. Closes parity gap with Win32 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE.
Previous fix used `run -127 bash "$FIND"` to silence BW01, but `-N`
syntax requires bats-core >= 1.5.0. Ubuntu's apt ships bats 1.2,
so on the CI runner this turned BW01 into BW02 (unknown flag warning).
Sidestep both warnings entirely by skipping `run` for this single test:
bash "$FIND" >/dev/null 2>&1
status=$?
[ "$status" -eq 127 ]
`run` is a bats convenience that captures stdout/stderr into $output
plus exit code into $status, but here we don't need stdout -- only
the exit code -- so plain $? works and bats has nothing to warn about.
All other tests in the file still use `run` because they assert on
$output as well as $status.
Net effect: 0 BW01, 0 BW02 in the CI log.
Previous "direct invocation" fix had a worse problem than the warning
it was avoiding: bats treats every bare command in a test body like
`set -e`. `bash "$FIND"` returning 127 aborts the test BEFORE the
`status=$?` line runs, so the assertion never executes -- the test
fails with the literal bash error rather than checking the exit code.
CI run 25652909510 made this concrete:
not ok 2 find-claude: returns 127 when no claude exists anywhere
# `bash "$FIND" >/dev/null 2>&1' failed with status 127
Canonical bats idiom for "expect this to fail with a specific code":
local status=0
bash "$FIND" >/dev/null 2>&1 || status=$?
[ "$status" -eq 127 ]
The `||` makes the failure "handled" from bats' perspective, so the
test continues; `status=$?` captures the real exit code for the
assertion. No `run` -> no BW01. No `-N` -> no BW02. No `set -e`
abort on intentional failure -> test actually runs.
After this CI should be 19/19 green:
- test 1 (cgroup.kill strong path) ALREADY GREEN as of cfe7479
(watchdog works -- SIGKILL of wrapper now reaps via cgroup.kill)
- test 2 (find-claude 127) flips green with this fix
- tests 3-19 already green
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
tools/linux/find-claude.sh(9-probe path resolver) +tools/linux/claude-jobbed.sh(two-tier wrapper:systemd-run --scope+cgroup.killstrong path on kernel 5.14+;set -m+ trap-on-signal +killpgfallback for older kernels / containers / WSL1).install.sh— OS-detect router with idempotent shell-function injection into~/.bashrc/~/.zshrc/~/.config/fish/config.fish,--yes/--force/--uninstallflags. Refuses cleanly on macOS (points at v1.2.0 milestone) and unknown OS.cgroup.killparity test against Win32KILL_ON_JOB_CLOSE, installer idempotency)..github/workflows/test.yml— first CI in this repo. Runs bats onubuntu-latest(withloginctl enable-lingerso the cgroup test exercises the strong path instead of skipping) and the existing PowerShell suite onwindows-latest(retroactive Wave A coverage closure).Branch:
linux-portoffv1.0.3(47d7e5a). 5 atomic commits, one logical change each, readable as a changelog viagit log --oneline.Architecture decisions (locked at start of branch)
tools/linux/,tools/macos/(later), top-levelinstall.shrouter. One README, one git history.--userisn't active.Test plan
ubuntu-latest(all 18 bats tests pass; cgroup-kill test should NOT skip — linger is enabled in the workflow)windows-latest(existing PowerShell suite, retroactively gated)bats tests/linux/test-cgroup-kill.batsexercises the strong path on the runner (verify in CI log: should not contain "skipped:")bats tests/linux/test-pgid-cleanup.batsexercises the fallback path viaCLAUDE_JOBBED_FORCE_FALLBACK=1(independent of runner systemd state)./install.sh --yeson a Linux box;claude --versionroutes through wrapper;./install.sh --uninstallcleanly removesOn green CI
Tag
v1.1.0(annotated) →gh release create v1.1.0with the guarantee matrix in the body → update auto-memory + Project Index.