Skip to content

feat: v1.1.0 Linux support (cgroup.kill + process-group fallback) - #1

Merged
ron2k1 merged 10 commits into
mainfrom
linux-port
May 11, 2026
Merged

ron2k1 merged 10 commits into
mainfrom
linux-port

Conversation

@ron2k1

@ron2k1 ron2k1 commented May 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds Linux runtime: tools/linux/find-claude.sh (9-probe path resolver) + tools/linux/claude-jobbed.sh (two-tier wrapper: systemd-run --scope + cgroup.kill strong path on kernel 5.14+; set -m + trap-on-signal + killpg fallback for older kernels / containers / WSL1).
  • Adds top-level install.sh — OS-detect router with idempotent shell-function injection into ~/.bashrc / ~/.zshrc / ~/.config/fish/config.fish, --yes / --force / --uninstall flags. Refuses cleanly on macOS (points at v1.2.0 milestone) and unknown OS.
  • Adds 18 bats tests across 4 files (find-claude probes, pgid cleanup on fallback path, kernel-enforced cgroup.kill parity test against Win32 KILL_ON_JOB_CLOSE, installer idempotency).
  • Adds .github/workflows/test.yml — first CI in this repo. Runs bats on ubuntu-latest (with loginctl enable-linger so the cgroup test exercises the strong path instead of skipping) and the existing PowerShell suite on windows-latest (retroactive Wave A coverage closure).
  • Updates README with explicit guarantee matrix (Win STRONG / Linux 5.14+ STRONG / Linux <5.14 MEDIUM / macOS WEAK-v1.2.0). Fixes a misclaim that said macOS "already has OS-level reapers (equivalent semantics on macOS)" — it doesn't, which is why Wave D is a separate milestone.

Branch: linux-port off v1.0.3 (47d7e5a). 5 atomic commits, one logical change each, readable as a changelog via git log --oneline.

Architecture decisions (locked at start of branch)

  • Monorepo with platform subdirs (Q1) — single repo, tools/linux/, tools/macos/ (later), top-level install.sh router. One README, one git history.
  • Both cgroup.kill (5.14+) AND pdeathsig fallback (Q4) — installer detects kernel and prints which tier you're getting. ~30 extra LOC for older kernels / containers / WSL1; honest skip in CI when systemd --user isn't active.
  • macOS NOT in this PR — v1.2.0 milestone. The reason it's not in v1.1.0 is the kernel guarantee gap (no SIGKILL-surviving primitive), and pretending it's at parity would undermine the whole pitch.

Test plan

  • CI green on ubuntu-latest (all 18 bats tests pass; cgroup-kill test should NOT skip — linger is enabled in the workflow)
  • CI green on windows-latest (existing PowerShell suite, retroactively gated)
  • bats tests/linux/test-cgroup-kill.bats exercises the strong path on the runner (verify in CI log: should not contain "skipped:")
  • bats tests/linux/test-pgid-cleanup.bats exercises the fallback path via CLAUDE_JOBBED_FORCE_FALLBACK=1 (independent of runner systemd state)
  • Manual smoke after merge: ./install.sh --yes on a Linux box; claude --version routes through wrapper; ./install.sh --uninstall cleanly removes

On green CI

Tag v1.1.0 (annotated) → gh release create v1.1.0 with the guarantee matrix in the body → update auto-memory + Project Index.

ron2k1 added 10 commits May 11, 2026 01:30
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
@ron2k1
ron2k1 merged commit 2b28322 into main May 11, 2026
4 checks passed
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.

1 participant