diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78387f0..03b512c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,6 +49,65 @@ jobs: chmod +x tools/linux/*.sh install.sh bats --print-output-on-failure tests/linux/ + # ----- macOS: bats suite for tools/macos/ + install.sh (MEDIUM tier) ---- + # arm64 only (macos-14). Hosted Intel macOS (macos-13) was dropped in + # v1.2.0: GitHub is retiring hosted Intel macOS, so the macos-13 leg + # never got a runner -- every run sat in "awaiting a runner" until + # GitHub's hard 24h account ceiling and was auto-cancelled. timeout-minutes + # bounds execution time AFTER a runner is assigned, never queue time, so + # no workflow knob can rescue a starved label. The runtime scripts are + # architecture-neutral (process groups / signals / ps, no arch-specific + # paths) and bash-3.2-safe by construction; the "Static bash-3.2 safety + # check" step below is the standing proof of the 3.2 constraint, so only + # x86_64 *execution* is uncovered, not syntax. Restoring an Intel + # execution leg is tracked as an explicit open gap: issue #3. Matrix kept + # single-entry (not collapsed) so re-adding Intel is a one-line change; + # fail-fast stays off for when it returns. + macos: + name: bats (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-14] + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Install bats-core + # bats-core is the canonical Homebrew formula (it installs the + # `bats` binary); the bare `bats` formula is the dead pre-1.0 one. + run: brew install bats-core + + - name: Probe environment (record which bash actually runs the suite) + # macOS ships Apple's frozen bash 3.2.57 at /bin/bash, but GitHub + # runners also put a modern Homebrew bash earlier on PATH. The + # wrapper is written 3.2-safe by construction; this records in the + # log which bash the functional suite exercises so the gap is + # documented, not hidden. + run: | + sw_vers + echo "arch: $(uname -m)" + echo "bats: $(bats --version)" + echo "PATH bash: $(bash --version | head -1)" + echo "/bin/bash: $(/bin/bash --version | head -1) <- Apple stock 3.2.57" + + - name: Static bash-3.2 safety check (parse under Apple stock /bin/bash) + # bats may run the suite under Homebrew bash, so separately prove + # the runtime scripts at least PARSE under the frozen 3.2.57 every + # real macOS user has. Catches syntax-level 3.2 breakage directly + # instead of trusting the runner's newer bash to stand in for it. + run: | + /bin/bash -n tools/macos/claude-jobbed.sh + /bin/bash -n tools/macos/find-claude.sh + /bin/bash -n install.sh + echo "tools/macos/*.sh + install.sh parse clean under bash 3.2.57" + + - name: Run bats suite + run: | + chmod +x tools/macos/*.sh install.sh + bats --print-output-on-failure tests/macos/ + # ----- Windows: existing PowerShell suite (Wave A retroactive coverage) -- windows: name: pwsh (windows-latest) diff --git a/DESIGN.md b/DESIGN.md index 6a9f5d2..f27a50d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,9 +1,10 @@ # claude-code-structured-concurrency — Design Specification -> Version: 1.0.0 -> Status: shipped — 22+1 tests passing, 9ms reap latency verified on Windows 11 build 26200 +> Version: 1.2.0 +> Status: shipped — Windows STRONG (v1.0.0), Linux STRONG+MEDIUM (v1.1.0), macOS MEDIUM + cmd.exe shim (v1.2.0). 36+1 pwsh + 40 bats tests passing; 9ms reap latency verified on Windows 11 build 26200. > Author: Ronil Basu ([@ron2k1](https://github.com/ron2k1)) > Created: 2026-05-07 +> Revised: 2026-05-16 — v1.2.0 macOS MEDIUM tier + cmd.exe shim ## Naming note @@ -11,7 +12,7 @@ The skill is named after the OS-level concept (**structured concurrency**) -- th ## Problem statement -Claude Code (CC) on Windows spawns N stdio MCP child processes per session, where N grows with active plugins. Each stdio MCP is a 2-3 process chain: `cmd.exe → npx.cmd → node.exe` (or `cmd.exe → uvx → python.exe`). When CC exits ungracefully — terminal X-button close, parent crash, OS task-end — those chains are not signaled. They stay alive until the OS reboots. +Claude Code (CC) spawns N stdio MCP child processes per session, where N grows with active plugins. Each stdio MCP is a 2-3 process chain — on Windows `cmd.exe → npx.cmd → node.exe` (or `cmd.exe → uvx → python.exe`); on Linux/macOS the analogous `sh → npx → node` / `sh → uvx → python`. When CC exits ungracefully — terminal X-button close, parent crash, OS task-end — those chains are not signaled. They stay alive until the OS reboots. Cumulative effect, observed 2026-05-07: - 14 user-global MCPs + ~30 plugin MCPs ≈ 40-60 node.exe per active session @@ -23,7 +24,32 @@ Cumulative effect, observed 2026-05-07: - Killing all `node.exe` by name alone — would terminate active CC itself. The decision flow always checks `spare_classifications` first, so `claude.exe` (classified as `claude`) cannot be killed even if `node.exe` is in `kill_names`. This invariant is exercised explicitly in `tests/test-config-loader.ps1`. - Replacing CC's own subprocess discipline. Anthropic's harness can and should ship Job Objects natively; this skill is the user-side workaround until then. -- Running on macOS or Linux. Those platforms already have OS-level reapers (`prctl(PR_SET_PDEATHSIG)` + cgroups on Linux, equivalent semantics on macOS). +- A STRONG (kernel-enforced, SIGKILL-proof) guarantee on macOS. macOS has no Job Object, no `cgroup.kill`, and no `prctl(PR_SET_PDEATHSIG)` — there is no kernel primitive that atomically reaps a process subtree on ancestor death. macOS is MEDIUM by construction (process group + `trap` + a disowned out-of-process watchdog); the honest ceiling — a simultaneous `kill -9` of both wrapper and watchdog — is stated in the guarantee matrix below and pinned by `tests/macos/test-honesty.bats`. Closing that gap would need a Swift `kqueue`/`launchd` helper and is conditional on telemetry. + +> Note: an earlier draft listed "Running on macOS or Linux" as a non-goal, asserting those platforms "already have OS-level reapers ... equivalent semantics on macOS." That was wrong on both counts: Linux shipped STRONG+MEDIUM in v1.1.0 and macOS shipped MEDIUM in v1.2.0, and macOS specifically has *no* such reaper — that absence is the entire reason it tops out at MEDIUM. The only genuine remaining non-goal is the macOS STRONG gap above. + +## Cross-platform guarantee matrix + +The design goal is identical on every platform — *the OS, not application code, enforces parent-death cleanup* — but the available kernel primitive differs, so the strength of the guarantee differs. That difference is stated honestly rather than papered over; the install banner and the test suite both encode it. + +| Platform | Mechanism | Survives SIGKILL of wrapper? | Tier | +|---|---|---|---| +| Windows 10+ | Win32 Job Object + `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` | Yes — kernel reaps on job-handle close, including Task Manager End-Task | **STRONG** (v1.0.0) | +| Linux ≥5.14 | `systemd-run --user --scope` + `cgroup.kill` | Yes — cgroup-level kill, kernel-enforced; an out-of-process watchdog supervises the scope so even SIGKILL of the wrapper still triggers `cgroup.kill` | **STRONG** (v1.1.0) | +| Linux <5.14 / no systemd / WSL1 | `set -m` + `trap` on EXIT/INT/TERM/HUP + `killpg` | No — a bash trap cannot fire on `kill -9` | **MEDIUM** (v1.1.0) | +| macOS | `set -m` + `trap` + disowned out-of-process watchdog | Partial — survives Force-Quit/SIGKILL of the wrapper *alone* (the watchdog outlives it and reaps the tree); does **not** survive a simultaneous SIGKILL of wrapper *and* watchdog | **MEDIUM** (v1.2.0) | + +### Why macOS is MEDIUM, not STRONG + +Windows has the Job Object; Linux ≥5.14 has `cgroup.kill`. macOS has neither, and `prctl(PR_SET_PDEATHSIG)` is Linux-only. There is no macOS syscall that says "kill this whole subtree when the ancestor dies." The MEDIUM design extracts the maximum the OS allows: + +1. `set -m` puts the spawned `claude` in its own process group, so it can be `killpg`'d without signaling the wrapper. +2. `trap 'cleanup' EXIT INT TERM HUP` handles every *catchable* exit of the wrapper. +3. A disowned watchdog subshell — in its *own* process group, so step 2's `killpg` cannot take it down — records the wrapper's PID and start time (`ps -p $pid -o lstart=`; macOS has no `/proc`) and polls. When the wrapper vanishes for *any* reason, including the un-catchable `kill -9` that defeats step 2, the watchdog runs the same `cleanup()` and reaps the tree. On graceful exit the wrapper `kill -KILL`s the watchdog (the watchdog traps catchable signals by design, so SIGTERM would not stop it). + +The watchdog is what lifts macOS from WEAK (`setpgid`+`trap` only, which dies with the wrapper on `kill -9`) to MEDIUM. The residual gap — a *simultaneous* `kill -9` of wrapper and watchdog — is unrecoverable because nothing is left alive and macOS has no kernel fallback. That exact scenario is asserted, and proven still-failing-by-design, in `tests/macos/test-honesty.bats`, so the ceiling is documented as an executable test, not just prose. + +This reuses the Linux v1.1.0 watchdog architecture: the out-of-process supervisor pattern was first built so the Linux STRONG path could survive a wrapper SIGKILL (the watchdog re-triggers `cgroup.kill`). macOS borrows the same supervisor idea but, lacking `cgroup.kill`, tops out at MEDIUM instead of STRONG. ## Architecture @@ -126,21 +152,26 @@ Tests in `tests/`: 1. `test-job-object.ps1` -- functional proof that `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` works on this Windows build. Spawns a sleeping `node`-like child, closes the job handle, asserts the child died within 2 seconds. **Verified 9ms reap latency on Windows 11 build 26200.** 2. `test-orphan-detect.ps1` -- 9 unit tests on synthetic process snapshots: orphan detection (with PID-reuse guard via `StartTime` comparison), classification, descendant tree walk. 3. `test-config-loader.ps1` -- 9 unit tests on the config schema: defaults, malformed-JSON fallback, partial-config merge, and the spare-wins-over-kill safety invariant. +4. `test-spawn-plan.ps1` -- 14 assertions on wrapper host-routing for `.cmd`/`.bat`/`.ps1` Claude shims (npm-installed Claude ships a shim, not an `.exe`) and extension-priority resolution. +5. `tests/linux/*.bats` (18) + `tests/macos/*.bats` (22) -- find-claude probe priority, pgid/watchdog cleanup, installer idempotency, and the two load-bearing *negative* tests: `tests/linux/test-cgroup-kill.bats` (Linux STRONG must survive wrapper SIGKILL via `cgroup.kill`) and `tests/macos/test-honesty.bats` (the macOS MEDIUM ceiling — simultaneous wrapper+watchdog SIGKILL leaks by design, and is asserted to still leak so it cannot silently regress). -All three suites must pass before any release tag. **22+1 tests passing as of v1.0.0.** CI on Windows runners is a v1.1 follow-up. +All suites must pass before any release tag. **36+1 PowerShell + 40 bats (18 Linux + 22 macOS) passing as of v1.2.0.** CI runs `ubuntu-latest`, `macos-14`, and `windows-latest` on every push; see `.github/workflows/test.yml`. Hosted Intel `macos-13` was dropped in v1.2.0 — GitHub is retiring hosted Intel macOS, so that leg never received a runner and ran to GitHub's hard 24h "awaiting a runner" ceiling (`timeout-minutes` bounds execution after assignment, never queue time); the scripts are architecture-neutral and bash-3.2-safe by construction, so only x86_64 *execution* is uncovered, tracked as an explicit open gap (#3). (CI on Windows runners was the v1.1 follow-up promised in the original spec; it shipped in v1.1.0 alongside the Linux port and was extended to macOS in v1.2.0.) ## Open questions (deferred) -- WSL interaction: if user runs `claude` from WSL, does the wrapper need a Linux equivalent? (Likely no -- WSL already reaps via cgroups, but verify.) +- ~~WSL interaction: if user runs `claude` from WSL, does the wrapper need a Linux equivalent?~~ Resolved in v1.1.0. WSL2 (real Linux kernel ≥5.14) takes the Linux STRONG `cgroup.kill` path; WSL1 (no real cgroup v2 / systemd) falls to the MEDIUM `set -m`+`trap` path. `CLAUDE_JOBBED_FORCE_FALLBACK=1` exercises that fallback in CI on a systemd-equipped runner. +- Intel (x86_64) macOS execution coverage: dropped from CI in v1.2.0 — GitHub is retiring hosted Intel macOS, so the `macos-13` leg never received a runner (it ran to GitHub's hard 24h "awaiting a runner" ceiling on every push, since `timeout-minutes` bounds execution after assignment, never queue time). The runtime scripts are architecture-neutral and bash-3.2-safe by construction, and the `/bin/bash -n` static-parse step still proves the 3.2 syntax constraint, so only x86_64 *execution* is uncovered. Restoring an Intel execution leg (self-hosted or paid runner) is tracked in #3. - Multi-session telemetry: should we track per-session spawn counts and report at end? (v1.1 feature.) - Plugin authors writing their own predicates: pluggable filter chain at the Layer-2 level? (v1.2 if asked.) - Windows Server / older Windows 10 builds: Job Object behavior was unreliable pre-build 17134 (January 2018). Currently documented as a hard floor; could be loosened with a runtime probe. ## Versioning -- v1.0.0 -- this release. Three tools, three test suites, config-driven predicate, four starter profiles, SessionStart hook, install script. -- v1.1.0 -- CI on Windows runners; multi-session telemetry; configurable log retention. -- v1.2.0 -- pluggable filter chain; companion macOS/Linux verifier (so cross-platform users can lint-check their config). +- v1.0.0 -- Windows. Three tools, three test suites, config-driven predicate, four starter profiles, SessionStart hook, install script. +- v1.0.2 / v1.0.3 -- installer `-ShadowClaude` (plain `claude` routes through the wrapper as a function, not a `Set-Alias`); wrapper host-routes npm-installed `.cmd`/`.ps1` Claude shims through `cmd.exe /c` / `powershell.exe -File`. +- v1.1.0 -- Linux. `tools/linux/` find-claude (9-probe) + two-tier wrapper: STRONG via `systemd-run --user --scope` + `cgroup.kill` (kernel ≥5.14) with an out-of-process watchdog supervising the scope, MEDIUM `set -m`+`trap` fallback for older kernels / no-systemd / WSL1. GitHub Actions matrix added (`ubuntu-latest` + `windows-latest`). bats suite. +- v1.2.0 -- macOS + cmd.exe (this release). `tools/macos/` find-claude (bash-3.2-safe; fnm probe also checks `~/Library/Application Support/fnm`) + MEDIUM wrapper (`set -m` + `trap` + disowned out-of-process watchdog; honest simultaneous-SIGKILL ceiling pinned by `tests/macos/test-honesty.bats`). `tools/claude-jobbed.cmd` shim so cmd.exe inherits the Windows STRONG Job Object. CI extended with the `macos-14` (Apple Silicon) leg plus a `/bin/bash -n` static-parse step against Apple's stock 3.2.57; hosted Intel `macos-13` execution was dropped (GitHub is retiring hosted Intel macOS; the leg never got a runner) and is tracked as an explicit open gap (#3). +- Deferred -- pluggable Layer-2 filter chain; multi-session spawn telemetry; configurable log retention; a Swift `kqueue`/`launchd` helper to lift macOS toward STRONG (conditional on telemetry showing real demand). ## Related work diff --git a/README.md b/README.md index 8f2ad13..b72c8de 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # claude-code-structured-concurrency -> Kernel-enforced cleanup of orphaned Claude Code subprocesses. Win32 Job Object on Windows, `cgroup.kill` (Linux 5.14+) with a process-group fallback for older kernels. macOS support tracked for v1.2.0. +> Kernel-enforced cleanup of orphaned Claude Code subprocesses. Win32 Job Object on Windows, `cgroup.kill` (Linux 5.14+) with a process-group fallback for older kernels, and `setpgid` + a disowned out-of-process watchdog on macOS. cmd.exe delegates to the PowerShell wrapper. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Platform](https://img.shields.io/badge/platform-Windows%2010%2B%20%7C%20Linux-blue)](#requirements) +[![Platform](https://img.shields.io/badge/platform-Windows%2010%2B%20%7C%20Linux%20%7C%20macOS-blue)](#requirements) [![Shell](https://img.shields.io/badge/shell-PowerShell%205.1%2B%20%7C%20bash-5391FE)](#requirements) -[![Tests](https://img.shields.io/badge/tests-36%2B1%20pwsh%20%7C%2018%20bats-brightgreen)](#tests) +[![Tests](https://img.shields.io/badge/tests-36%2B1%20pwsh%20%7C%2040%20bats-brightgreen)](#tests) Claude Code spawns 40-60 child processes per session (MCP servers, plugins, LSPs, hooks). They often outlive their parent. After a few days, Task Manager (Windows) or `ps -ef` (Linux) fills with `node` entries from sessions that closed hours ago, and reboot becomes the cleanup primitive. This skill wires up the same kernel mechanisms Chrome, Edge, VS Code, and `systemd-run --scope` already use to bound helper-process lifetime, so the OS reaps the tree instead. -Verified 9 ms reap latency on Windows 11 build 26200. 36 PowerShell unit assertions plus 1 functional test (Windows side) and 18 bats tests (Linux side), all passing in CI. +Verified 9 ms reap latency on Windows 11 build 26200. 36 PowerShell unit assertions plus 1 functional test (Windows side) and 40 bats tests (Linux + macOS), all passing in CI. ## Guarantee matrix @@ -18,9 +18,9 @@ Verified 9 ms reap latency on Windows 11 build 26200. 36 PowerShell unit asserti | Windows 10+ | Win32 Job Object + `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` | Yes (kernel reaps on handle close, including Task Manager End-Task) | **STRONG** — shipped v1.0.0 | | Linux 5.14+ | `systemd-run --user --scope` + `cgroup.kill` | Yes (cgroup-level kill, kernel-enforced) | **STRONG** — shipped v1.1.0 | | Linux <5.14 / containers / WSL1 | bash `set -m` + `trap` on EXIT/INT/TERM/HUP + `killpg` | No (trap doesn't fire on `kill -9`) | **MEDIUM** — fallback, shipped v1.1.0 | -| macOS | `setpgid` + `trap` (planned) | No (Force-Quit of wrapper escapes cleanup) | **WEAK** — v1.2.0 milestone, with explicit honesty in install banner | +| macOS | `setpgid` + `trap` + disowned out-of-process watchdog | Yes if only the wrapper is killed (the watchdog outlives it and reaps the tree); No if wrapper and watchdog are SIGKILLed simultaneously | **MEDIUM** — shipped v1.2.0; honest ceiling stated in the install banner and pinned by `tests/macos/test-honesty.bats` | -Running on Linux <5.14? The installer prints which tier you're getting at install time, so there's no surprise. +Running on Linux <5.14, or on macOS? The installer prints which tier you're getting at install time, so there's no surprise.

Three-layer architecture: Visibility (cc-procs.ps1) and Cleanup (cleanup-orphans.ps1) and Prevention (claude-jobbed.ps1) over shared libraries, with the Prevention layer connecting to the Win32 Job Object kernel primitive @@ -44,10 +44,14 @@ Running on Linux <5.14? The installer prints which tier you're getting at instal - bash 4+. The fallback path uses `set -m` job control and trap-on-signal cleanup. - Optional but recommended: kernel 5.14+ (Aug 2021, in every supported distro) and `systemd-run` available, for the STRONG `cgroup.kill` path. Without these the installer drops to the MEDIUM trap-based fallback and tells you so. -Zero external dependencies on either platform. No PowerShell modules, no Node, no Python, no `sudo`. +**macOS side:** +- bash — Apple's stock `/bin/bash` 3.2.57 is sufficient; the wrapper, finder, and installer are written 3.2-safe by construction. zsh and fish are also wired. +- macOS has no `cgroup.kill` / Job-Object analog, so it is MEDIUM tier by construction (`setpgid` + `trap` + disowned out-of-process watchdog). The installer states this ceiling explicitly at install time — there is no STRONG path to opt into. + +Zero external dependencies on any platform. No PowerShell modules, no Node, no Python, no `sudo`. > [!IMPORTANT] -> **Windows: PowerShell only. cmd.exe is not supported.** The wrapper alias relies on `$PROFILE`, which is a PowerShell concept. cmd.exe has no equivalent profile mechanism, so plain `claude` typed into a cmd.exe window bypasses the wrapper and runs unprotected. Use PowerShell or Git Bash. Per-launcher details and remedies live in [`docs/FAQ.md`](docs/FAQ.md). +> **Windows: automatic shadowing is PowerShell-only — but cmd.exe is supported.** `-ShadowClaude` redefines `claude` via `$PROFILE`, a PowerShell concept; cmd.exe has no equivalent AutoRun profile, so plain `claude` typed into a bare cmd.exe window runs unprotected. cmd.exe users are not stuck: [`tools\claude-jobbed.cmd`](tools/claude-jobbed.cmd) delegates to the PowerShell wrapper and inherits the exact same **STRONG** Job Object guarantee. Invoke it directly, or add a per-session macro with `doskey claude=C:\path\to\tools\claude-jobbed.cmd $*`. PowerShell and Git Bash get automatic shadowing. Per-launcher details and remedies live in [`docs/FAQ.md`](docs/FAQ.md). ## Install @@ -70,7 +74,9 @@ Get-Command claude # CommandType=Application -> NOT wrapped ``` -### Linux +**cmd.exe:** there is no AutoRun auto-shadow, but [`tools\claude-jobbed.cmd`](tools/claude-jobbed.cmd) delegates to `claude-jobbed.ps1` and inherits the same STRONG Job Object guarantee. Run it directly, or add a per-session macro: `doskey claude=C:\path\to\tools\claude-jobbed.cmd $*`. + +### Linux / macOS ```bash git clone https://github.com/ron2k1/claude-code-structured-concurrency \ @@ -80,7 +86,7 @@ cd "$HOME/.claude/skills/structured-concurrency" ./install.sh ``` -The installer detects your kernel version, prints which guarantee tier you're getting (STRONG on 5.14+, MEDIUM below), and asks for `[y/N]` confirmation. It injects an idempotent shell function block into `~/.bashrc`, `~/.zshrc`, and `~/.config/fish/config.fish` (each only if the rc file already exists), so plain `claude` routes through the wrapper. +The same `install.sh` covers Linux and macOS. On Linux it detects your kernel version and prints which guarantee tier you're getting (STRONG on 5.14+, MEDIUM below). On macOS it prints the MEDIUM tier and the honest ceiling: the disowned watchdog reaps the tree if the wrapper alone is Force-Quit, but a *simultaneous* SIGKILL of both wrapper and watchdog is unrecoverable because macOS has no kernel job-object/cgroup primitive. Either way it asks for `[y/N]` confirmation. It injects an idempotent shell function block into `~/.bashrc`, `~/.zshrc`, and `~/.config/fish/config.fish` (each only if the rc file already exists); on macOS it also writes `~/.bash_profile`, because macOS Terminal.app runs bash as a login shell and login shells source `~/.bash_profile`, not `~/.bashrc`. Plain `claude` then routes through the wrapper. ```bash # CI / unattended: @@ -113,6 +119,7 @@ type claude | [`tools/cc-procs.ps1`](tools/cc-procs.ps1) | Visibility | Read-only inventory: PID, parent, age, memory, classification, orphan flag. No kill capability. | | [`tools/cleanup-orphans.ps1`](tools/cleanup-orphans.ps1) | Cleanup | Terminates strict-orphan subtrees per `~/.reap/config.json`. Dry-run by default. | | [`tools/claude-jobbed.ps1`](tools/claude-jobbed.ps1) | Prevention | Win32 Job Object wrapper. Kernel terminates the entire CC tree on wrapper exit. | +| [`tools/claude-jobbed.cmd`](tools/claude-jobbed.cmd) | Prevention | cmd.exe shim. Re-execs `claude-jobbed.ps1` via `powershell.exe -NoProfile -File` and propagates its exit code, so cmd.exe users inherit the same STRONG Job Object guarantee. | **Linux (`tools/linux/`):** @@ -121,6 +128,13 @@ type claude | [`tools/linux/find-claude.sh`](tools/linux/find-claude.sh) | Discovery | 9-probe path resolver: `command -v` → npm prefix → `/opt/homebrew/bin` → `/usr/local/bin` → nvm (highest version) → fnm → asdf → volta → yarn global. Returns 127 if nothing matches. | | [`tools/linux/claude-jobbed.sh`](tools/linux/claude-jobbed.sh) | Prevention | Two-tier wrapper. STRONG: spawns `claude` inside `systemd-run --user --scope`, so `cgroup.kill` reaps the tree even on `kill -9` of the wrapper. FALLBACK: bash `set -m` + trap-on-EXIT/INT/TERM/HUP that issues `killpg -TERM` then `-KILL`. `CLAUDE_JOBBED_FORCE_FALLBACK=1` exercises the fallback path on systemd-equipped boxes (used in CI). | +**macOS (`tools/macos/`):** + +| Tool | Layer | What it does | +|------|-------|--------------| +| [`tools/macos/find-claude.sh`](tools/macos/find-claude.sh) | Discovery | Same 9-probe order as Linux, written bash-3.2-safe (Apple ships frozen bash 3.2.57 at `/bin/bash`). The fnm probe additionally checks `~/Library/Application Support/fnm`, fnm's default `FNM_DIR` on macOS. | +| [`tools/macos/claude-jobbed.sh`](tools/macos/claude-jobbed.sh) | Prevention | MEDIUM-tier wrapper. `set -m` gives the child its own process group; a `trap` reaps it on graceful exit or catchable signal; a disowned out-of-process watchdog (its own pgid, parent-identity check via `ps -p $pid -o lstart=` since macOS has no `/proc`) reaps the tree even when the wrapper alone is Force-Quit. Simultaneous SIGKILL of wrapper and watchdog is the honest ceiling — macOS has no kernel job-object/cgroup primitive. | + ```powershell # Windows .\tools\cc-procs.ps1 # see what's running @@ -136,7 +150,7 @@ type claude # confirm: should print "claude is a function" Inside a Claude Code session on Windows, the same flow is `/structured-concurrency [kill|install|verify]`. -A SessionStart hook (`hooks/reap-on-start.ps1`) runs the cleanup in strict-orphan-only mode on every CC start (Windows), so leftovers from un-wrapped or crashed sessions are reaped automatically. The Linux wrapper does not need a periodic reaper — `cgroup.kill` runs at wrapper exit, not on a schedule. +A SessionStart hook (`hooks/reap-on-start.ps1`) runs the cleanup in strict-orphan-only mode on every CC start (Windows), so leftovers from un-wrapped or crashed sessions are reaped automatically. The Linux and macOS wrappers do not need a periodic reaper — cleanup runs at wrapper exit (Linux: `cgroup.kill`; macOS: the watchdog), not on a schedule. ## Auditable @@ -233,11 +247,21 @@ Fallback path (kernels <5.14, containers without systemd, WSL1): There is no application code path that can leak on the strong paths. This is structured concurrency enforced by the operating system, the way Nathaniel J. Smith [originally framed](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/) the problem class. Application discipline is what produced the leaks in the first place. +### macOS + +macOS has no `cgroup.kill` and no Win32 Job Object. `prctl(PR_SET_PDEATHSIG)` is Linux-only; there is no kernel primitive that atomically reaps a process subtree when an ancestor dies. The MEDIUM tier closes as much of that gap as the OS permits: + +1. `set -m` so the spawned `claude` gets its own process group. +2. `trap 'cleanup' EXIT INT TERM HUP` — graceful exit or any catchable signal `killpg`s the child group. +3. A disowned watchdog subshell with its *own* process group records the wrapper's PID and start time (`ps -p "$pid" -o lstart=` — there is no `/proc` on macOS to read), then polls. When the wrapper disappears — including a Force-Quit / `kill -9` that the wrapper's own trap can never catch — the watchdog runs the same `cleanup()` and reaps the tree. On graceful exit the wrapper `kill -KILL`s the watchdog, since the watchdog traps catchable signals and would otherwise outlive its purpose. + +The honest ceiling: a *simultaneous* `kill -9` of both the wrapper and the watchdog leaves the child group unreaped, because nothing is left alive to do it and macOS offers no kernel fallback. That exact scenario is asserted — and proven still-failing-by-design — in `tests/macos/test-honesty.bats`, so the limit is documented in executable form, not just prose. + Full architecture: [`DESIGN.md`](DESIGN.md). ## Tests -36 PowerShell unit assertions plus 1 functional test (Windows side) and 18 bats tests (Linux side), all passing in the GitHub Actions matrix. +36 PowerShell unit assertions plus 1 functional test (Windows side), 18 bats tests (Linux), and 22 bats tests (macOS), all passing in the GitHub Actions matrix. **Windows (`tests/test-*.ps1`):** @@ -252,11 +276,20 @@ Full architecture: [`DESIGN.md`](DESIGN.md). | Suite | Coverage | |-------|----------| -| `tests/linux/test-find-claude.bats` | Probe priority: PATH > npm prefix > nvm (highest version) > fnm > yarn global. Sandboxed PATH+HOME so probes only hit fixtures. The 127-when-not-found contract and source-mode contract. 8 tests. (Probes 3-4 — Homebrew paths — honestly skip on Linux runners; they land in v1.2.0 macOS CI.) | +| `tests/linux/test-find-claude.bats` | Probe priority: PATH > npm prefix > nvm (highest version) > fnm > yarn global. Sandboxed PATH+HOME so probes only hit fixtures. The 127-when-not-found contract and source-mode contract. 8 tests. (Probes 3-4 — Homebrew paths — honestly skip on Linux runners; they are exercised by the macOS suite on the macos-14 leg.) | | `tests/linux/test-pgid-cleanup.bats` | Forces fallback path via `CLAUDE_JOBBED_FORCE_FALLBACK=1`. Spawns a fake claude that backgrounds a grandchild, kills the wrapper with SIGTERM, polls (3s budget) for grandchild death. Plus exit-code propagation and verbatim arg forwarding. 3 tests. | | `tests/linux/test-cgroup-kill.bats` | Load-bearing parity test against Win32 `KILL_ON_JOB_CLOSE`. Lets the wrapper take the strong (`systemd-run --scope`) path, then SIGKILLs the wrapper. Bash traps don't fire on `-9`, so only kernel-enforced cleanup via `cgroup.kill` can satisfy this. Skips with a printed reason if `systemd-run` is missing, `--user` systemd is inactive, or kernel < 5.14. 1 test. | | `tests/linux/test-installer.bats` | Sandboxes HOME; covers `--yes` inject, idempotent re-run preserving marker count, `--force` overwrite (count stays at 2 not 4), `--uninstall` clean removal, `--uninstall` no-op, and unknown-flag exit code 2. 6 tests. | +**macOS (`tests/macos/test-*.bats`):** + +| Suite | Coverage | +|-------|----------| +| `tests/macos/test-find-claude.bats` | Same probe-priority contract as Linux, plus the macOS-specific fnm `~/Library/Application Support/fnm` probe. Sandboxed PATH+HOME. 10 tests. | +| `tests/macos/test-pgid-cleanup.bats` | Spawns a fake claude that backgrounds a grandchild, kills the wrapper with SIGTERM, polls for grandchild death. Plus exit-code propagation and verbatim arg forwarding. Single macOS path (no FORCE_FALLBACK split). 3 tests. | +| `tests/macos/test-honesty.bats` | The load-bearing negative test that pins the honest MEDIUM ceiling. CASE 1: SIGKILL the wrapper alone — the disowned watchdog must outlive it and reap the grandchild (proves MEDIUM). CASE 2: SIGKILL wrapper and watchdog simultaneously — the grandchild survives, the documented un-closeable ceiling on a kernel with no job-object primitive. 2 tests. | +| `tests/macos/test-installer.bats` | Sandboxes HOME; covers `--yes` inject into `~/.zshrc` + `~/.bashrc` + `~/.bash_profile`, the honest-ceiling install banner text, idempotent re-run, `--force` no-dup, `--uninstall` clean removal across all three rc files, `--uninstall` no-op, and unknown-flag exit code 2. 7 tests. | + ```powershell # Windows .\tests\test-job-object.ps1 @@ -268,9 +301,12 @@ Full architecture: [`DESIGN.md`](DESIGN.md). ```bash # Linux (requires bats-core: apt install bats) bats --print-output-on-failure tests/linux/ + +# macOS (requires bats-core: brew install bats-core) +bats --print-output-on-failure tests/macos/ ``` -CI runs both halves on every push (`.github/workflows/test.yml`): `ubuntu-latest` for the bats suite (with `loginctl enable-linger` so `systemctl --user` is active and the cgroup-kill test exercises the strong path instead of skipping), and `windows-latest` for the PowerShell suite. If a suite fails on your Windows build, file an issue with the output of `winver`. If it fails on a Linux distro, include `uname -r` and `systemctl --user is-active default.target`. +CI runs all three platforms on every push (`.github/workflows/test.yml`): `ubuntu-latest` for the Linux bats suite (with `loginctl enable-linger` so `systemctl --user` is active and the cgroup-kill test exercises the strong path instead of skipping), `macos-14` (Apple Silicon) for the macOS bats suite, and `windows-latest` for the PowerShell suite. Hosted Intel macOS (`macos-13`) is intentionally not a CI leg — GitHub is retiring hosted Intel macOS, so that leg never received a runner and ran to GitHub's hard 24h "awaiting a runner" ceiling on every push; the runtime scripts are architecture-neutral and bash-3.2-safe by construction, so only x86_64 *execution* is uncovered, tracked as an explicit open gap in [#3](https://github.com/ron2k1/claude-code-structured-concurrency/issues/3). The macOS leg includes a probe step that records `sw_vers` / `uname -m` and which bash actually runs the suite: GitHub's macOS runners put a modern Homebrew bash ahead of Apple's frozen `/bin/bash` 3.2.57 on PATH, so a dedicated `/bin/bash -n` static-parse step proves the runtime scripts parse under real Apple stock bash even though bats itself runs under the newer bash — the gap is documented, not hidden. If a suite fails on your Windows build, file an issue with the output of `winver`. If it fails on a Linux distro, include `uname -r` and `systemctl --user is-active default.target`; on macOS include `sw_vers` and `uname -m`. ## Safety guarantees @@ -282,9 +318,9 @@ CI runs both halves on every push (`.github/workflows/test.yml`): `ubuntu-latest ## What this does not do - Replace Claude Code's own subprocess discipline. Anthropic can ship Job Objects + cgroups natively. This is the user-side workaround until they do. -- Help on macOS yet. macOS has neither `cgroup.kill` nor a Job-Object equivalent — `prctl(PR_SET_PDEATHSIG)` is Linux-only, and `setpgid` + `atexit` cleanup does *not* survive `kill -9` of the wrapper (Activity Monitor "Force Quit"). The v1.2.0 milestone ships a process-group wrapper with explicit honesty about this gap in the install banner; the v1.3.0+ Swift `kqueue` helper is conditional on telemetry. +- Fully match the Windows/Linux STRONG tier on macOS. macOS has neither `cgroup.kill` nor a Job-Object equivalent — `prctl(PR_SET_PDEATHSIG)` is Linux-only. v1.2.0 ships the MEDIUM tier: `setpgid` + `trap` + a disowned out-of-process watchdog, which *does* survive Force-Quit of the wrapper alone (the watchdog outlives it and reaps the tree). The honest ceiling — a *simultaneous* `kill -9` of both wrapper and watchdog — is unrecoverable, and that exact ceiling is pinned by `tests/macos/test-honesty.bats` so it cannot silently regress. A future Swift `kqueue`/`launchd` helper that could close the gap is conditional on telemetry. - Wrap a `claude` that's already running. Restart your shell after install (Windows or Linux). -- Cover launchers that bypass shell rc files: on Windows that's `cmd.exe`, `Win+R`, desktop shortcuts to `claude.exe`, Task Scheduler entries, VS Code's terminal until reloaded; on Linux that's anything launched with `env -i` or by a service manager that strips `~/.bashrc`. See [`docs/FAQ.md`](docs/FAQ.md) for per-path remedies (Windows side; Linux equivalents land with the next docs pass). +- Auto-shadow launchers that bypass shell rc files: `Win+R`, desktop shortcuts to `claude.exe`, Task Scheduler entries, VS Code's terminal until reloaded; on Linux/macOS that's anything launched with `env -i` or by a service manager that strips the rc files. Bare cmd.exe has no AutoRun auto-shadow either — though [`tools\claude-jobbed.cmd`](tools/claude-jobbed.cmd) gives cmd.exe users the full STRONG guarantee when invoked or aliased explicitly. See [`docs/FAQ.md`](docs/FAQ.md) for per-path remedies. ## License diff --git a/SKILL.md b/SKILL.md index fbbeeae..218b8d3 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,30 +1,36 @@ --- name: structured-concurrency -description: Use when the user types `/structured-concurrency`, mentions orphan or zombie processes, MCP process leaks, node.exe accumulation, Claude Code subprocess bloat, "task manager full of node processes", subprocess leak, structured concurrency on Windows, kill-on-close, Win32 Job Objects, or wants to reap leftover children from prior Claude Code sessions. Also triggers on questions about why memory fills up after multiple CC sessions, before planning heavy multi-session work that needs subprocess hygiene, or when troubleshooting "claude code spawned too many processes". +description: Use when the user types `/structured-concurrency`, mentions orphan or zombie processes, MCP process leaks, node.exe (or `node`) accumulation, Claude Code subprocess bloat, "task manager full of node processes", "activity monitor full of node", subprocess leak, structured concurrency, kill-on-close, Win32 Job Objects, `cgroup.kill`, `setpgid`/`PR_SET_PDEATHSIG`, or wants to reap leftover children from prior Claude Code sessions. Cross-platform -- this skill covers Windows (PowerShell AND cmd.exe), macOS, and Linux, so make sure to use it for subprocess-hygiene questions on ANY of those platforms, including a Mac user asking how to stop Claude Code leaking processes, "orphaned node processes on my mac", launching `claude` so its children die with it, or wrapping CC in a job / cgroup / process group. Also triggers on questions about why memory fills up after multiple CC sessions, before planning heavy multi-session work that needs subprocess hygiene, or when troubleshooting "claude code spawned too many processes". --- -# /structured-concurrency -- Claude Code Subprocess Lifetime Manager (Windows) +# /structured-concurrency -- Claude Code Subprocess Lifetime Manager (Windows, macOS, Linux) ## Overview -Claude Code spawns dozens of child processes per session (MCP servers, plugin runtimes, LSPs, hook scripts). On graceful exit they should die. They often don't -- Windows has no `init` reaper, and stdio MCPs leak as `cmd.exe -> npx.cmd -> node.exe` chains. Across sessions this compounds into multi-gigabyte zombies that only a reboot clears. +Claude Code spawns dozens of child processes per session (MCP servers, plugin runtimes, LSPs, hook scripts). On graceful exit they should die. They often don't -- neither Windows nor macOS reaps a process subtree when the ancestor dies, and stdio MCPs leak as `cmd.exe -> npx.cmd -> node.exe` on Windows or `sh -> npx -> node` on macOS/Linux. Across sessions this compounds into multi-gigabyte zombies that only a reboot clears. -This is the same problem Nathaniel J. Smith framed as "structured concurrency" in 2018: child task lifetimes should be bounded by their parent, enforced by the runtime, not by application discipline. Trio, Kotlin coroutines, and Swift Concurrency solved it at the language level. Linux has the OS primitive (`prctl(PR_SET_PDEATHSIG)` + cgroups). Windows has the OS primitive too -- Win32 Job Objects with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` -- but no language runtime wires it up for Node.js child processes. This skill does. +This is the same problem Nathaniel J. Smith framed as "structured concurrency" in 2018: child task lifetimes should be bounded by their parent, enforced by the OS, not by application discipline. Trio, Kotlin coroutines, and Swift Concurrency solved it at the language level. The OS-level primitive differs by platform, so the strength of the guarantee differs too -- and that difference is stated honestly rather than papered over: + +- **Windows** -- Win32 Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Kernel-enforced, survives SIGKILL / Task-Manager End-Task: **STRONG**. +- **Linux >= 5.14** -- `cgroup.kill` via `systemd-run --user --scope`, with an out-of-process watchdog supervising the scope: **STRONG**. Older kernels / no-systemd / WSL1 fall back to `setpgid` + `trap`: **MEDIUM** (a bash trap cannot fire on `kill -9`). +- **macOS** -- has NONE of those primitives (no Job Object, no `cgroup.kill`, no `prctl(PR_SET_PDEATHSIG)`), so it uses `setpgid` + `trap` + a disowned out-of-process watchdog: **MEDIUM**. It survives Force-Quit / SIGKILL of the wrapper *alone* (the watchdog outlives it and reaps the tree) but NOT a simultaneous SIGKILL of wrapper *and* watchdog. That honest ceiling is pinned by `tests/macos/test-honesty.bats` so it cannot silently regress. + +No language runtime wires any of this up for Node.js child processes. This skill does, on all three platforms. Three layers of fix, each composable: -1. **Diagnostic** (`cc-procs.ps1`) -- read-only inventory of every CC-related process, parent chain, age, classification, orphan status -2. **Cleanup** (`cleanup-orphans.ps1`) -- terminate strict orphans (and their descendants) per `~/.reap/config.json`. Dry-run default; safe-no-op when no config exists. See `docs/CONFIGURATION.md`. -3. **Prevention** (`claude-jobbed.ps1`) -- Win32 Job Object wrapper. The kernel terminates the entire CC process tree when the wrapper exits, even on crash, BSOD, or X-button close. +1. **Diagnostic** (`cc-procs.ps1`, Windows) -- read-only inventory of every CC-related process, parent chain, age, classification, orphan status +2. **Cleanup** (`cleanup-orphans.ps1`, Windows) -- terminate strict orphans (and their descendants) per `~/.reap/config.json`. Dry-run default; safe-no-op when no config exists. See `docs/CONFIGURATION.md`. +3. **Prevention** -- the OS reaps the entire CC tree when the wrapper exits, even on crash, BSOD, Force-Quit, or X-button close. This is the cross-platform layer: `tools/claude-jobbed.ps1` (Windows Job Object), `tools/claude-jobbed.cmd` (cmd.exe shim that re-execs the PowerShell wrapper, inheriting the same STRONG Job Object), `tools/linux/claude-jobbed.sh` (cgroup.kill scope, or setpgid+trap fallback), `tools/macos/claude-jobbed.sh` (setpgid+trap+disowned watchdog). ## When to Use -Trigger on: -- Task Manager shows many `Node.js JavaScript Runtime` or `Windows Command Processor` entries +Trigger on (any platform): +- Windows Task Manager shows many `Node.js JavaScript Runtime` / `Windows Command Processor` entries; OR macOS Activity Monitor / `ps aux | grep node` shows piled-up `node`; OR Linux `ps`/`htop` shows orphaned `node` reparented to PID 1 - Memory pressure after several CC sessions -- "Why are there 80 node.exe processes?" +- "Why are there 80 node.exe processes?" / "why is my Mac full of `node` processes?" - Before any heavy multi-session work where leak compounding would hurt -- After a CC crash, hard close, or terminal X-button kill +- After a CC crash, hard close, Force-Quit, or terminal X-button kill Do **not** trigger on: - High CPU from a *single* legit MCP (that's a different problem -- kill that MCP, not orphans) @@ -40,6 +46,12 @@ Do **not** trigger on: | `/structured-concurrency verify` | Run `tests/` -- proves Job Object kill-on-close works on this machine | | `/structured-concurrency wrap ` | Launch CC under the Job Object wrapper for one session | +The table above is the Windows (PowerShell) surface. **Platform dispatch:** + +- **cmd.exe** -- call `tools\claude-jobbed.cmd ` (or `doskey claude=C:\path\to\tools\claude-jobbed.cmd $*`). It re-execs the PowerShell wrapper and inherits the same STRONG Job Object. +- **macOS / Linux** -- run `bash install.sh` (auto-detects the platform, picks the STRONG or MEDIUM tier, shadows `claude` via `~/.bashrc` plus `~/.bash_profile` on macOS for Terminal.app's login shell), or invoke `tools/macos/claude-jobbed.sh` / `tools/linux/claude-jobbed.sh ` directly. +- The diagnostic and cleanup layers (`cc-procs.ps1`, `cleanup-orphans.ps1`) are Windows-only; on macOS/Linux the prevention wrapper *is* the hygiene story (cleanup happens at wrapper exit, not on a schedule), so there is no periodic-reaper equivalent to port. + ## Workflow When invoked: @@ -47,7 +59,7 @@ When invoked: 1. **Default (no args)**: run `tools/cc-procs.ps1` and report tree + orphan count + memory total. 2. **`kill`**: run `tools/cleanup-orphans.ps1 -Force` with strict-orphan filter and report kills. 3. **`install`**: run `tools/install-reap.ps1`, confirm it modified `$PROFILE` + `~/.bashrc`; show backup path. SessionStart hook wiring is documented but the user opts in by editing `~/.claude/settings.json` after a few dry-run sessions. -4. **`verify`**: run `tests/test-job-object.ps1`, `tests/test-orphan-detect.ps1`, and `tests/test-config-loader.ps1`. All must pass before claiming the wrapper works. +4. **`verify`**: run the Windows suite (`tests/test-job-object.ps1`, `tests/test-orphan-detect.ps1`, `tests/test-config-loader.ps1`, `tests/test-spawn-plan.ps1`). The POSIX suites live in `tests/linux/*.bats` and `tests/macos/*.bats` and run in CI on the `ubuntu-latest` / `macos-14` legs (bats is not on Windows; hosted Intel `macos-13` execution is an open gap, see #3). All must pass before claiming the wrapper works. 5. **`wrap`**: invoke `tools/claude-jobbed.ps1` with the user's args, do not return until child claude exits. If user asks "what's running" without typing the slash command, still invoke `cc-procs.ps1` -- that's the read-only diagnostic, zero risk. @@ -55,35 +67,64 @@ If user asks "what's running" without typing the slash command, still invoke `cc ## File Map ``` -~/.claude/skills/structured-concurrency/ +Repo root (== installed skill dir ~/.claude/skills/structured-concurrency/). +Tree below mirrors `git ls-files` exactly: + +-- SKILL.md (this file) +-- README.md (public-facing pitch, repo-ready) -+-- DESIGN.md (architecture spec) ++-- DESIGN.md (architecture spec + cross-platform guarantee matrix) ++-- SECURITY.md (vulnerability disclosure policy) +-- LICENSE (MIT) ++-- .gitignore ++-- install.sh (POSIX installer: macOS + Linux; auto-detects tier, shadows claude via ~/.bashrc + ~/.bash_profile on macOS) +-- tools/ -| +-- cc-procs.ps1 (diagnostic, read-only) -| +-- cleanup-orphans.ps1 (reaper, config-driven via ~/.reap/config.json) -| +-- claude-jobbed.ps1 (Job Object wrapper) -| +-- install-reap.ps1 (one-time setup + seeds ~/.reap/config.json) +| +-- cc-procs.ps1 (Layer 1 diagnostic, read-only -- Windows) +| +-- cleanup-orphans.ps1 (Layer 2 reaper, config-driven via ~/.reap/config.json -- Windows) +| +-- claude-jobbed.ps1 (Layer 3 wrapper -- Win32 Job Object, Windows) +| +-- claude-jobbed.cmd (cmd.exe shim -> re-execs claude-jobbed.ps1, inherits the Job Object) +| +-- install-reap.ps1 (Windows one-time setup + seeds ~/.reap/config.json) | +-- lib/ -| +-- JobObject.ps1 (Win32 P/Invoke for kill-on-close) -| +-- ProcessTree.ps1 (parent-chain analysis + classifier) -| +-- ConfigLoader.ps1 (JSON config schema + Test-ReapPredicate) +| | +-- JobObject.ps1 (Win32 P/Invoke for kill-on-close) +| | +-- ProcessTree.ps1 (parent-chain analysis + classifier) +| | +-- ConfigLoader.ps1 (JSON config schema + Test-ReapPredicate) +| | +-- SpawnPlan.ps1 (Claude-shim host-routing: .cmd/.bat/.ps1 resolution) +| +-- linux/ +| | +-- claude-jobbed.sh (STRONG cgroup.kill via systemd-run scope; MEDIUM setpgid+trap fallback) +| | +-- find-claude.sh (9-probe claude resolver) +| +-- macos/ +| +-- claude-jobbed.sh (MEDIUM setpgid+trap+disowned watchdog; bash-3.2-safe) +| +-- find-claude.sh (9-probe; fnm probe also checks ~/Library/Application Support/fnm) +-- hooks/ -| +-- reap-on-start.ps1 (SessionStart hook handler) +| +-- reap-on-start.ps1 (SessionStart hook handler -- Windows) +-- tests/ -| +-- test-job-object.ps1 (spawn -> kill wrapper -> verify reaped) -| +-- test-orphan-detect.ps1 (9 unit tests on snapshot + classifier) -| +-- test-config-loader.ps1 (9 unit tests on config schema + predicate) +| +-- test-job-object.ps1 (functional: spawn -> kill wrapper -> verify reaped) +| +-- test-orphan-detect.ps1 (unit: snapshot + classifier) +| +-- test-config-loader.ps1 (unit: config schema + spare-wins-over-kill invariant) +| +-- test-spawn-plan.ps1 (unit: Claude-shim host-routing, 14 assertions) +| +-- linux/ +| | +-- test-find-claude.bats (probe priority) +| | +-- test-pgid-cleanup.bats (setpgid + trap reap) +| | +-- test-cgroup-kill.bats (NEGATIVE: STRONG must survive wrapper SIGKILL via cgroup.kill) +| | +-- test-installer.bats (install.sh idempotency + Linux rc contract) +| +-- macos/ +| +-- test-find-claude.bats (probe priority + fnm dual-dir) +| +-- test-pgid-cleanup.bats (setpgid + trap + watchdog reap) +| +-- test-honesty.bats (NEGATIVE: pins the honest MEDIUM ceiling -- simultaneous SIGKILL leaks by design) +| +-- test-installer.bats (install.sh idempotency + ~/.bash_profile + honest-ceiling banner) +-- config-examples/ | +-- conservative.json (spare almost everything) | +-- moderate.json (shipped default) | +-- aggressive.json (also kills node.exe / cmd.exe by name) | +-- paranoid.json (observe-only, never kills) +-- docs/ - +-- CONFIGURATION.md (schema reference + escape-hatch + patterns) - -User-side state (NOT in repo, created by install-reap.ps1): +| +-- CONFIGURATION.md (schema reference + escape-hatch + patterns) +| +-- FAQ.md (common questions) +| +-- architecture.svg (diagram) +| +-- demo.mp4 (screen capture) ++-- .github/workflows/ + +-- test.yml (CI matrix: ubuntu-latest, macos-14, windows-latest) + +User-side state (NOT in repo, created by install-reap.ps1 / install.sh): ~/.reap/ +-- config.json (user's reap config) +-- predicate.ps1 (OPTIONAL procedural override) @@ -121,3 +162,5 @@ Post-skill: - Job Object wrapper: zero orphans on exit, kernel-enforced (verified 9ms reap latency on Windows 11 build 26200) - SessionStart hook: leftovers from un-wrapped sessions cleared on next CC start - Diagnostic surface: `cc-procs.ps1` shows orphan count + memory total any time + +macOS and Linux: validated in CI (GitHub `macos-14` / `ubuntu-latest` legs; hosted Intel `macos-13` execution dropped, tracked in #3), not locally micro-benched. The bats suites assert the wrapper reaps the tree and, for macOS, that the honest MEDIUM ceiling still holds (`tests/macos/test-honesty.bats`). The latency figures above are Windows-measured only -- no synthetic numbers are claimed for the POSIX tiers. diff --git a/install.sh b/install.sh index f338e07..7875996 100644 --- a/install.sh +++ b/install.sh @@ -6,7 +6,10 @@ # routes through the wrapper. # # Windows users: run install-reap.ps1 from PowerShell instead. -# macOS users: not yet supported (v1.2.0 milestone). +# macOS: supported at the MEDIUM tier (setpgid + out-of-process watchdog; +# survives Force-Quit / kill -9 of the wrapper alone, but NOT a +# simultaneous kill -9 of wrapper AND watchdog -- macOS has no +# cgroup.kill or Job Object equivalent). The banner restates this. # # Flags: # --yes / -y non-interactive (skip the prompt; required for CI) @@ -48,9 +51,9 @@ case "$uname_s" in fi ;; Darwin) - printf 'install.sh: macOS support is on the v1.2.0 roadmap, not v1.1.0.\n' >&2 - printf ' Track: https://github.com/ron2k1/claude-code-structured-concurrency/milestones\n' >&2 - exit 1 + platform=macos + mac_ver="$(sw_vers -productVersion 2>/dev/null || echo '?')" + guarantee="MEDIUM (setpgid + out-of-process watchdog; macOS $mac_ver). Survives Force-Quit / kill -9 of the wrapper alone. NOT survivable if the wrapper AND its watchdog are kill -9'd simultaneously -- macOS has no cgroup.kill or Job Object equivalent. This is the honest ceiling, not a TODO." ;; *) printf 'install.sh: unsupported OS %s\n' "$uname_s" >&2 @@ -85,7 +88,9 @@ inject_into() { if grep -qF "$block_marker_open" "$rc" 2>/dev/null; then if [ "$force" -eq 1 ]; then # remove old block first (sed in-place; portable form needs tmp) - local tmp; tmp="$(mktemp)" + # Explicit template: GNU mktemp allows a bare `mktemp`, BSD + # mktemp (macOS) requires a template. This form works on both. + local tmp; tmp="$(mktemp "${TMPDIR:-/tmp}/csc-block.XXXXXX")" awk -v marker_open="$block_marker_open" -v marker_close="$block_marker_close" ' $0 == marker_open { skip = 1; next } $0 == marker_close { skip = 0; next } @@ -107,7 +112,8 @@ uninstall_from() { if ! grep -qF "$block_marker_open" "$rc" 2>/dev/null; then return 0 fi - local tmp; tmp="$(mktemp)" + # portable mktemp (BSD/macOS needs an explicit template; see inject_into) + local tmp; tmp="$(mktemp "${TMPDIR:-/tmp}/csc-block.XXXXXX")" awk -v marker_open="$block_marker_open" -v marker_close="$block_marker_close" ' $0 == marker_open { skip = 1; next } $0 == marker_close { skip = 0; next } @@ -117,19 +123,46 @@ uninstall_from() { printf ' removed from %s\n' "$rc" } +# --- rc-file targets (single source of truth: install AND uninstall use +# these so the two can never drift). macOS Terminal.app runs bash as a +# LOGIN shell, which reads ~/.bash_profile and NOT ~/.bashrc -- so macOS +# must target .bash_profile too. zsh is the macOS default shell since +# Catalina; .zshrc covers it on every platform. inject_into/uninstall_from +# already skip files that don't exist, so listing extras is harmless. +# Explicit if/then (not `[ ] && cmd`) because set -e would abort on the +# false branch's non-zero compound exit. -------------------------------- + +inject_all() { + inject_into "$HOME/.zshrc" + if [ "$platform" = "macos" ]; then inject_into "$HOME/.bash_profile"; fi + inject_into "$HOME/.bashrc" + inject_into "$HOME/.config/fish/config.fish" +} + +uninstall_all() { + uninstall_from "$HOME/.zshrc" + if [ "$platform" = "macos" ]; then uninstall_from "$HOME/.bash_profile"; fi + uninstall_from "$HOME/.bashrc" + uninstall_from "$HOME/.config/fish/config.fish" +} + # --- uninstall path ------------------------------------------------------ if [ "$uninstall" -eq 1 ]; then printf 'Uninstalling claude-code-structured-concurrency shell-function block:\n' - uninstall_from "$HOME/.bashrc" - uninstall_from "$HOME/.zshrc" - uninstall_from "$HOME/.config/fish/config.fish" + uninstall_all printf 'Done. Restart your shell to take effect.\n' exit 0 fi # --- install path: pre-flight ack ---------------------------------------- +if [ "$platform" = "macos" ]; then + rc_human="~/.zshrc, ~/.bash_profile, ~/.bashrc, ~/.config/fish/config.fish" +else + rc_human="~/.bashrc, ~/.zshrc, ~/.config/fish/config.fish" +fi + cat < "$target" + chmod +x "$target" +} + +# --- Probe 1: PATH -------------------------------------------------------- + +@test "find-claude(macos): returns 127 when no claude exists anywhere" { + # `cmd || status=$?` -- the `||` makes failure expected to bats so the + # test doesn't abort on bash's non-zero exit, AND we capture the actual + # exit code. Avoids `run` (BW01 on 127-exit) and `run -127` (BW02 on + # the older bats some runners ship, which lacks -N support). + local status=0 + bash "$FIND" >/dev/null 2>&1 || status=$? + [ "$status" -eq 127 ] +} + +@test "find-claude(macos): locates claude via PATH (probe 1)" { + local d="$SANDBOX/onpath" + make_fake_claude "$d/claude" + PATH="$d:$PATH" run bash "$FIND" + [ "$status" -eq 0 ] + [ "$output" = "$d/claude" ] +} + +# --- Probe 2: npm prefix -------------------------------------------------- + +@test "find-claude(macos): locates claude via npm prefix (probe 2)" { + local npm_dir="$SANDBOX/npm-prefix" + make_fake_claude "$npm_dir/bin/claude" + + local mock_dir="$SANDBOX/mock-bin" + mkdir -p "$mock_dir" + cat > "$mock_dir/npm" < "$mock_dir/npm" < "$hb" + chmod +x "$hb" + # The script hardcodes /opt/homebrew/bin/claude; rename our marker in. + if [ -e /opt/homebrew/bin/claude ]; then + skip "real claude already present in /opt/homebrew/bin -- not clobbering it" + fi + mv "$hb" /opt/homebrew/bin/claude + run bash "$FIND" + rm -f /opt/homebrew/bin/claude + [ "$status" -eq 0 ] + [ "$output" = "/opt/homebrew/bin/claude" ] +} + +# --- Probe 5: nvm --------------------------------------------------------- + +@test "find-claude(macos): locates claude via nvm, highest version wins (probe 5)" { + make_fake_claude "$HOME/.nvm/versions/node/v18.20.0/bin/claude" + make_fake_claude "$HOME/.nvm/versions/node/v22.5.0/bin/claude" + + run bash "$FIND" + [ "$status" -eq 0 ] + [ "$output" = "$HOME/.nvm/versions/node/v22.5.0/bin/claude" ] +} + +# --- Probe 6: fnm -- BOTH layouts (the one real macOS divergence) --------- + +@test "find-claude(macos): locates claude via fnm XDG layout (probe 6a)" { + make_fake_claude "$HOME/.local/share/fnm/aliases/default/bin/claude" + run bash "$FIND" + [ "$status" -eq 0 ] + [ "$output" = "$HOME/.local/share/fnm/aliases/default/bin/claude" ] +} + +@test "find-claude(macos): locates claude via fnm macOS Library layout (probe 6b)" { + # This is the macOS-specific path the Linux suite never exercises. + make_fake_claude "$HOME/Library/Application Support/fnm/aliases/default/bin/claude" + run bash "$FIND" + [ "$status" -eq 0 ] + [ "$output" = "$HOME/Library/Application Support/fnm/aliases/default/bin/claude" ] +} + +# --- Probe 9: yarn global ------------------------------------------------- + +@test "find-claude(macos): locates claude via yarn global (probe 9)" { + make_fake_claude "$HOME/.config/yarn/global/node_modules/.bin/claude" + run bash "$FIND" + [ "$status" -eq 0 ] + [ "$output" = "$HOME/.config/yarn/global/node_modules/.bin/claude" ] +} + +# --- Source-mode contract ------------------------------------------------- + +@test "find-claude(macos): sourceable -- find_claude defined, no script side effect" { + local d="$SANDBOX/onpath" + make_fake_claude "$d/claude" + + PATH="$d:/usr/bin:/bin" run bash -c ". '$FIND' && declare -F find_claude && find_claude" + [ "$status" -eq 0 ] + [[ "$output" == *"find_claude"* ]] + [[ "$output" == *"$d/claude"* ]] +} diff --git a/tests/macos/test-honesty.bats b/tests/macos/test-honesty.bats new file mode 100644 index 0000000..73f3fb4 --- /dev/null +++ b/tests/macos/test-honesty.bats @@ -0,0 +1,183 @@ +#!/usr/bin/env bats +# THE honesty test for the macOS port. It exists to make the wrapper's +# guarantee claim falsifiable and to stop it from ever silently drifting +# into a lie. +# +# tools/macos/claude-jobbed.sh claims MEDIUM: +# +# CASE 1 (we beat WEAK): a SIGKILL of the wrapper ALONE is survived -- +# the disowned, separate-process watchdog notices the wrapper vanish +# and reaps the child group. A plain setpgid+trap wrapper (no watchdog) +# would leak here because bash traps never fire on SIGKILL. This test +# asserts the grandchild DIES. +# +# CASE 2 (we do NOT claim STRONG): a SIMULTANEOUS SIGKILL of BOTH the +# wrapper AND the watchdog leaks the tree. macOS has no kernel primitive +# (cgroup.kill / Job Object) to cover this the way Linux 5.14+ and +# Windows do. This test asserts the grandchild SURVIVES -- i.e. it pins +# the honest ceiling. If someone "fixes" this into a pass, they have +# either added a real kernel primitive (great -- update the guarantee) +# or, far more likely, written something that does not actually hold. +# Either way the diff must explain itself. +# +# A negative assertion ("the leak still happens") is unusual on purpose: +# the install banner and DESIGN.md both state this ceiling in words; this +# test is what keeps those words true. + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + WRAPPER="$REPO_ROOT/tools/macos/claude-jobbed.sh" + [ -x "$WRAPPER" ] || chmod +x "$WRAPPER" + [ -x "$REPO_ROOT/tools/macos/find-claude.sh" ] || chmod +x "$REPO_ROOT/tools/macos/find-claude.sh" + + SANDBOX="$(mktemp -d "${TMPDIR:-/tmp}/csc-honesty.XXXXXX")" + ORIG_PATH="$PATH" + + cat > "$SANDBOX/claude" < "$SANDBOX/grandchild.pid" +wait +FAKE + chmod +x "$SANDBOX/claude" + export PATH="$SANDBOX:$PATH" + : > "$SANDBOX/cleanup.pids" +} + +teardown() { + # Best-effort net. Case 2 deliberately leaves a live tree; kill + # everything we ever recorded plus the grandchild file. + if [ -f "$SANDBOX/cleanup.pids" ]; then + local p + while read -r p; do + [ -n "$p" ] && kill -KILL "$p" 2>/dev/null || true + done < "$SANDBOX/cleanup.pids" + fi + if [ -f "$SANDBOX/grandchild.pid" ]; then + local gc; gc="$(cat "$SANDBOX/grandchild.pid" 2>/dev/null || echo)" + [ -n "$gc" ] && kill -KILL "$gc" 2>/dev/null || true + fi + export PATH="$ORIG_PATH" + rm -rf "$SANDBOX" +} + +# Wait for the fake claude to publish its grandchild PID; echo it. +_await_grandchild() { + local i=0 + while [ ! -s "$SANDBOX/grandchild.pid" ] && [ $i -lt 30 ]; do + sleep 0.1 + i=$((i + 1)) + done + [ -s "$SANDBOX/grandchild.pid" ] || return 1 + cat "$SANDBOX/grandchild.pid" +} + +# Discover the watchdog PID from outside. Process tree: +# wrapper(W) -> { fake-claude(C), watchdog-subshell(Wd) } +# fake-claude(C) -> sleep(G) [G = grandchild.pid] +# So C = parent of G, and Wd = the child of W that is not C. We poll until +# W has exactly its two children so the identification is unambiguous. +_discover_pids() { + local wpid="$1" gpid="$2" + local cpid kids i=0 + cpid="$(ps -o ppid= -p "$gpid" 2>/dev/null | tr -d ' ')" + [ -n "$cpid" ] || return 1 + while [ $i -lt 30 ]; do + kids="$(pgrep -P "$wpid" 2>/dev/null | tr '\n' ' ')" + # Expect two children (fake-claude + watchdog). + set -- $kids + if [ "$#" -ge 2 ]; then + break + fi + sleep 0.1 + i=$((i + 1)) + done + local wd="" k + for k in $kids; do + if [ "$k" != "$cpid" ]; then + wd="$k" + fi + done + [ -n "$wd" ] || return 1 + printf '%s %s\n' "$cpid" "$wd" +} + +@test "honesty CASE 1: SIGKILL of wrapper ALONE is survived (proves MEDIUM)" { + "$WRAPPER" & + local wrapper_pid=$! + echo "$wrapper_pid" >> "$SANDBOX/cleanup.pids" + + local gc_pid; gc_pid="$(_await_grandchild)" + [ -n "$gc_pid" ] + echo "$gc_pid" >> "$SANDBOX/cleanup.pids" + kill -0 "$gc_pid" # alive before we strike + + # SIGKILL the wrapper ONLY. Its bash trap cannot fire (SIGKILL is + # uncatchable). The watchdog is a separate process and survives; it + # must notice and reap within its 0.2s poll + cleanup grace. + kill -KILL "$wrapper_pid" + + # Watchdog poll (<=0.2s) + cleanup (TERM, 0.5s, KILL) + slack. 5s cap. + local i=0 + while kill -0 "$gc_pid" 2>/dev/null && [ $i -lt 50 ]; do + sleep 0.1 + i=$((i + 1)) + done + + if kill -0 "$gc_pid" 2>/dev/null; then + kill -KILL "$gc_pid" 2>/dev/null || true + printf 'FAIL: grandchild %s survived a lone wrapper SIGKILL -- the watchdog did NOT reap. This is WEAK, not MEDIUM.\n' "$gc_pid" >&2 + return 1 + fi +} + +@test "honesty CASE 2: simultaneous SIGKILL of wrapper+watchdog leaks (pins the ceiling)" { + "$WRAPPER" & + local wrapper_pid=$! + echo "$wrapper_pid" >> "$SANDBOX/cleanup.pids" + + local gc_pid; gc_pid="$(_await_grandchild)" + [ -n "$gc_pid" ] + echo "$gc_pid" >> "$SANDBOX/cleanup.pids" + kill -0 "$gc_pid" + + local pids cpid watchdog_pid + pids="$(_discover_pids "$wrapper_pid" "$gc_pid")" + [ -n "$pids" ] + cpid="${pids%% *}" + watchdog_pid="${pids##* }" + echo "$cpid" >> "$SANDBOX/cleanup.pids" + echo "$watchdog_pid" >> "$SANDBOX/cleanup.pids" + + # Prove we actually found a real, distinct, live watchdog -- otherwise + # this test could "pass" trivially by having killed nothing. + [ -n "$watchdog_pid" ] + [ "$watchdog_pid" != "$wrapper_pid" ] + [ "$watchdog_pid" != "$cpid" ] + [ "$watchdog_pid" != "$gc_pid" ] + kill -0 "$watchdog_pid" + + # One syscall batch -> both get an uncatchable SIGKILL before either + # can run another instruction. The watchdog cannot reap because it is + # dead before its next poll iteration. This is the documented hole. + kill -KILL "$wrapper_pid" "$watchdog_pid" + + # Give any (nonexistent) cleanup the same generous window CASE 1 got. + # If the grandchild is going to be reaped it would happen well within + # this; we assert it is STILL ALIVE at the end. + local i=0 + while [ $i -lt 30 ]; do + sleep 0.1 + i=$((i + 1)) + done + + if ! kill -0 "$gc_pid" 2>/dev/null; then + printf 'UNEXPECTED: grandchild %s was reaped after a simultaneous wrapper+watchdog SIGKILL.\n' "$gc_pid" >&2 + printf ' macOS has no kernel primitive that should make this possible. Either a real\n' >&2 + printf ' primitive was added (update the guarantee to STRONG and this test), or the\n' >&2 + printf ' reap came from somewhere unaudited. Do not just flip the assertion.\n' >&2 + return 1 + fi + # Grandchild survived exactly as the MEDIUM ceiling says it must. + # teardown() will clean up the deliberately-leaked tree. +} diff --git a/tests/macos/test-installer.bats b/tests/macos/test-installer.bats new file mode 100644 index 0000000..d44fa6f --- /dev/null +++ b/tests/macos/test-installer.bats @@ -0,0 +1,133 @@ +#!/usr/bin/env bats +# Unit tests for install.sh on the macOS (Darwin) path. +# +# Faithful sibling of tests/linux/test-installer.bats: the idempotent +# inject / --force / --uninstall / unknown-flag contracts are identical +# across platforms, so those assertions keep the same shape. This file +# additionally pins the TWO things the Linux suite has no reason to carry: +# +# 1. ~/.bash_profile is targeted. macOS Terminal.app runs bash as a +# LOGIN shell, which sources ~/.bash_profile and NOT ~/.bashrc. The +# Linux installer deliberately never writes ~/.bash_profile; the +# Darwin branch must. A regression dropping this would leave every +# Terminal.app bash user with an installer that "succeeded" yet never +# actually wrapped `claude` -- the worst kind of silent failure. +# +# 2. The MEDIUM banner states the honest ceiling in words ("NOT +# survivable if the wrapper AND its watchdog are kill -9'd +# simultaneously ... This is the honest ceiling, not a TODO"). +# test-honesty.bats proves the BEHAVIOR; this proves we still tell +# the user the truth about it at install time. +# +# install.sh only takes its Darwin arm when `uname -s` is Darwin, so the +# whole suite skips with an honest reason on a non-macOS runner rather +# than a green that proves nothing. + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + INSTALL="$REPO_ROOT/install.sh" + [ -x "$INSTALL" ] || chmod +x "$INSTALL" + + SANDBOX="$(mktemp -d "${TMPDIR:-/tmp}/csc-installer.XXXXXX")" + ORIG_HOME="$HOME" + export HOME="$SANDBOX/home" + mkdir -p "$HOME" + touch "$HOME/.bashrc" + touch "$HOME/.zshrc" + # macOS LOGIN-shell bash reads THIS, not .bashrc -- inject_into skips + # files that don't exist, so it must pre-exist for the divergence to + # be observable. + touch "$HOME/.bash_profile" + # leave fish unset to verify "skip when absent" behavior + + # install.sh refuses to take the Darwin path off macOS (it would exit + # 1 as "unsupported OS" on anything that is neither Linux nor Darwin, + # and take the Linux arm on Linux). The macOS contract is only + # observable on macOS; anywhere else this suite skips honestly. + if [ "$(uname -s)" != "Darwin" ]; then + skip "install.sh's Darwin branch is unexercisable on $(uname -s); Linux path is covered by tests/linux/test-installer.bats" + fi +} + +teardown() { + export HOME="$ORIG_HOME" + rm -rf "$SANDBOX" +} + +@test "install.sh(macos): --yes injects into zsh + bashrc + bash_profile" { + run "$INSTALL" --yes + [ "$status" -eq 0 ] + + grep -qF "claude-code-structured-concurrency" "$HOME/.zshrc" + grep -qF "claude-code-structured-concurrency" "$HOME/.bashrc" + # THE macOS divergence: LOGIN-shell bash reads .bash_profile. The Linux + # installer never writes here; the Darwin branch must. + grep -qF "claude-code-structured-concurrency" "$HOME/.bash_profile" + # fish config wasn't created -- installer should NOT have made one. + [ ! -e "$HOME/.config/fish/config.fish" ] +} + +@test "install.sh(macos): banner states the honest MEDIUM ceiling" { + run "$INSTALL" --yes + [ "$status" -eq 0 ] + # We took the Darwin arm, not the "unsupported OS" exit. + [[ "$output" == *"Detected: Darwin"* ]] + # The tier, and -- critically -- the words that keep it honest. If a + # refactor ever softens this to "MEDIUM (TODO: harden)" or drops the + # ceiling sentence, this fails and forces the diff to explain itself. + [[ "$output" == *"MEDIUM"* ]] + [[ "$output" == *"honest ceiling"* ]] +} + +@test "install.sh(macos): re-running without --force is idempotent" { + "$INSTALL" --yes + local before + before="$(grep -cF "claude-code-structured-concurrency" "$HOME/.bash_profile")" + + run "$INSTALL" --yes + [ "$status" -eq 0 ] + + local after + after="$(grep -cF "claude-code-structured-concurrency" "$HOME/.bash_profile")" + [ "$before" -eq "$after" ] +} + +@test "install.sh(macos): --force overwrites the bash_profile block (no dup)" { + "$INSTALL" --yes + run "$INSTALL" --yes --force + [ "$status" -eq 0 ] + + # open + close marker == exactly twice per file, not 4x. + local marker_count + marker_count="$(grep -cE "(>>>|<<<) claude-code-structured-concurrency" "$HOME/.bash_profile")" + [ "$marker_count" -eq 2 ] +} + +@test "install.sh(macos): --uninstall removes the block from ALL mac rc files" { + "$INSTALL" --yes + grep -qF "claude-code-structured-concurrency" "$HOME/.bash_profile" + + run "$INSTALL" --uninstall + [ "$status" -eq 0 ] + + local rc + for rc in .zshrc .bashrc .bash_profile; do + if grep -qF "claude-code-structured-concurrency" "$HOME/$rc" 2>/dev/null; then + printf 'FAIL: marker still present in %s after --uninstall\n' "$rc" >&2 + return 1 + fi + done +} + +@test "install.sh(macos): --uninstall is a no-op when never installed" { + local before; before="$(cat "$HOME/.bash_profile")" + run "$INSTALL" --uninstall + [ "$status" -eq 0 ] + local after; after="$(cat "$HOME/.bash_profile")" + [ "$before" = "$after" ] +} + +@test "install.sh(macos): refuses unknown flags with exit 2" { + run "$INSTALL" --not-a-real-flag + [ "$status" -eq 2 ] +} diff --git a/tests/macos/test-pgid-cleanup.bats b/tests/macos/test-pgid-cleanup.bats new file mode 100644 index 0000000..7fb51a4 --- /dev/null +++ b/tests/macos/test-pgid-cleanup.bats @@ -0,0 +1,101 @@ +#!/usr/bin/env bats +# Integration test: graceful SIGTERM to the macOS wrapper reaps the child +# tree via the setpgid + bash-trap path, plus exit-code and arg-forwarding +# contracts. +# +# Unlike Linux, the macOS wrapper has NO CLAUDE_JOBBED_FORCE_FALLBACK +# toggle: there is a single path (setpgid + trap + out-of-process +# watchdog), always armed. So this file just drives that one path. The +# SIGKILL-of-wrapper case (where the trap canNOT fire and only the +# watchdog saves the tree) is the MEDIUM proof and lives in +# test-honesty.bats, alongside the negative test that pins the ceiling. + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + WRAPPER="$REPO_ROOT/tools/macos/claude-jobbed.sh" + [ -x "$WRAPPER" ] || chmod +x "$WRAPPER" + [ -x "$REPO_ROOT/tools/macos/find-claude.sh" ] || chmod +x "$REPO_ROOT/tools/macos/find-claude.sh" + + SANDBOX="$(mktemp -d "${TMPDIR:-/tmp}/csc-pgid.XXXXXX")" + ORIG_PATH="$PATH" + + # Fake claude: spawn a long-lived sleep child whose PID we can probe. + # `set -m` in the wrapper gives this fake its own process group, and + # the sleep stays in that group, so a group-directed kill reaps both. + cat > "$SANDBOX/claude" < "$SANDBOX/grandchild.pid" +wait +FAKE + chmod +x "$SANDBOX/claude" + export PATH="$SANDBOX:$PATH" +} + +teardown() { + if [ -f "$SANDBOX/grandchild.pid" ]; then + local gc; gc="$(cat "$SANDBOX/grandchild.pid" 2>/dev/null || echo)" + [ -n "$gc" ] && kill -KILL "$gc" 2>/dev/null || true + fi + export PATH="$ORIG_PATH" + rm -rf "$SANDBOX" +} + +@test "claude-jobbed(macos): SIGTERM to wrapper reaps child tree (trap path)" { + "$WRAPPER" & + local wrapper_pid=$! + + # Wait up to 3s for the fake claude to write the grandchild PID. + local i=0 + while [ ! -s "$SANDBOX/grandchild.pid" ] && [ $i -lt 30 ]; do + sleep 0.1 + i=$((i + 1)) + done + [ -s "$SANDBOX/grandchild.pid" ] + local gc_pid; gc_pid="$(cat "$SANDBOX/grandchild.pid")" + + # Sanity: grandchild is alive before we kill the wrapper. + kill -0 "$gc_pid" + + # SIGTERM the wrapper -- the EXIT/TERM trap fires cleanup() which + # SIGTERMs the child group, waits 500ms, then SIGKILLs stragglers. + kill -TERM "$wrapper_pid" + + # Grace is 500ms + scheduling slack; poll up to 3s. + i=0 + while kill -0 "$gc_pid" 2>/dev/null && [ $i -lt 30 ]; do + sleep 0.1 + i=$((i + 1)) + done + + if kill -0 "$gc_pid" 2>/dev/null; then + kill -KILL "$gc_pid" 2>/dev/null || true + printf 'FAIL: grandchild PID %s survived wrapper SIGTERM\n' "$gc_pid" >&2 + return 1 + fi +} + +@test "claude-jobbed(macos): graceful child exit propagates exit code" { + cat > "$SANDBOX/claude" <<'FAKE' +#!/usr/bin/env bash +exit 42 +FAKE + chmod +x "$SANDBOX/claude" + + run "$WRAPPER" + [ "$status" -eq 42 ] +} + +@test "claude-jobbed(macos): forwards args verbatim" { + cat > "$SANDBOX/claude" <<'FAKE' +#!/usr/bin/env bash +printf '%s\n' "$@" +FAKE + chmod +x "$SANDBOX/claude" + + run "$WRAPPER" --version --foo "bar baz" + [ "$status" -eq 0 ] + [ "${lines[0]}" = "--version" ] + [ "${lines[1]}" = "--foo" ] + [ "${lines[2]}" = "bar baz" ] +} diff --git a/tools/claude-jobbed.cmd b/tools/claude-jobbed.cmd new file mode 100644 index 0000000..2975360 --- /dev/null +++ b/tools/claude-jobbed.cmd @@ -0,0 +1,25 @@ +@echo off +REM claude-jobbed.cmd -- cmd.exe shim into the PowerShell Job Object wrapper. +REM +REM cmd.exe cannot host a Win32 Job Object itself: the CreateJobObjectW / +REM SetInformationJobObject P/Invoke that arms KILL_ON_JOB_CLOSE lives in +REM claude-jobbed.ps1. So instead of reimplementing it in batch (which +REM cannot call the Win32 API), this shim re-execs into PowerShell running +REM the SAME wrapper. A cmd.exe user therefore gets the IDENTICAL STRONG +REM kernel-enforced guarantee as launching from PowerShell -- no weaker +REM tier, no second code path to keep in sync. +REM +REM %~dp0 = directory of THIS .cmd (with trailing backslash), so the +REM sibling .ps1 resolves no matter the caller's cwd. +REM -NoProfile : skip the user's PS profile (fast, predictable). +REM -ExecutionPolicy Bypass : the user invoked this script directly; it is +REM the trust root, same convention v1.0.3 uses +REM for .ps1 shim host-routing (SpawnPlan). +REM %* = forward every argument verbatim. +REM +REM Usage: +REM claude-jobbed.cmd equivalent to plain `claude` +REM claude-jobbed.cmd --version any args forward transparently + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0claude-jobbed.ps1" %* +exit /b %ERRORLEVEL% diff --git a/tools/macos/claude-jobbed.sh b/tools/macos/claude-jobbed.sh new file mode 100644 index 0000000..1f05f7f --- /dev/null +++ b/tools/macos/claude-jobbed.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# claude-jobbed.sh -- macOS subprocess hygiene wrapper for claude. +# +# macOS is the hard platform. It has none of the three strong primitives +# the other ports lean on: +# - no Win32 Job Object (Windows STRONG) +# - no cgroup.kill (Linux 5.14+ STRONG) +# - no PR_SET_PDEATHSIG (Linux) +# and no /proc, so even the parent-identity trick the Linux strong path +# uses (`stat -c %Y /proc/$$`) is unavailable. +# +# A plain setpgid + trap wrapper -- the Linux FALLBACK shape -- is only +# WEAK on macOS: bash traps never fire on SIGKILL, so an Activity Monitor +# "Force Quit" (or `kill -9`) of the wrapper orphans the whole tree. +# +# We reach MEDIUM by porting the out-of-process watchdog the Linux STRONG +# path introduced (commit cfe7479). The watchdog is a SEPARATE, disowned +# process that polls the wrapper's identity and reaps the child process +# group the instant the wrapper vanishes. Because it is a distinct process, +# a SIGKILL aimed at the wrapper alone no longer escapes cleanup. +# +# Honest ceiling (pinned by tests/macos/test-honesty.bats): a SIMULTANEOUS +# kill -9 of BOTH the wrapper AND the watchdog still leaks. macOS has no +# kernel primitive (cgroup.kill / Job Object) to cover that the way Linux +# and Windows do. MEDIUM, not STRONG -- and the install banner says so. +# +# bash 3.2-safe on purpose: stock macOS /usr/bin/env bash resolves to +# Apple's frozen 3.2.57. No arrays, no [[ =~ ]], no ${x,,}, no mapfile. +# +# Usage (matches the Windows and Linux wrappers): +# claude-jobbed.sh # equivalent to plain `claude` +# claude-jobbed.sh --version # any args forward transparently +# claude-jobbed.sh -p "prompt" # ditto + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=find-claude.sh +. "$here/find-claude.sh" + +if ! claude_path="$(find_claude)"; then + printf 'claude-jobbed: claude not found in PATH or any known install location\n' >&2 + printf ' install Claude Code first (see https://claude.ai/code)\n' >&2 + exit 127 +fi + +# `set -m` enables job control so every backgrounded job gets its OWN +# process group (pgid == its own pid). This is load-bearing twice: +# 1. the child's group is killable as `-$child_pgid` without also +# signalling this wrapper, and +# 2. the watchdog lands in a DIFFERENT group than the child, so +# `kill -- -$child_pgid` never takes the watchdog down with it. +set -m + +child_pgid= + +# cleanup() is the single source of truth for the kill sequence. Both the +# in-process trap AND the watchdog call it, so the two paths can never +# drift apart. SIGTERM first (graceful), 500ms grace, then SIGKILL the +# stragglers -- identical shape to the Linux fallback's cleanup() so a +# reviewer reading both sees the same contract. +cleanup() { + if [ -n "${child_pgid:-}" ]; then + kill -TERM "-$child_pgid" 2>/dev/null || true + sleep 0.5 + kill -KILL "-$child_pgid" 2>/dev/null || true + fi +} + +# parent_identity: macOS has no /proc, so identity = PID + process start +# time via BSD `ps -o lstart=` (the `=` suppresses the header). A recycled +# PID gets a different start time, so a stale match is impossible except in +# the sub-second window where a new process reuses our exact PID AND starts +# within the same 1-second lstart tick -- the same residual-risk class as +# the Linux /proc-mtime approach (also 1s resolution). Trimmed identically +# on both sides so the comparison can never skew on whitespace padding. +parent_identity() { + ps -p "$1" -o lstart= 2>/dev/null | tr -s ' ' | sed 's/^ *//;s/ *$//' || true +} + +trap cleanup EXIT INT TERM HUP + +"$claude_path" "$@" & +child_pid=$! +child_pgid=$child_pid # set -m guarantees pgid == pid for bg jobs + +# --- watchdog: external supervisor for the SIGKILL-of-wrapper case ------- +# +# Disowned subshell. Ignores the catchable signals (so the parent shell's +# exit doesn't HUP it and a stray TERM can't kill it before its job is +# done), polls the wrapper's identity, and on the wrapper's disappearance +# reaps the child group via the SAME cleanup() defined above (inherited by +# the subshell fork). This is the WEAK -> MEDIUM upgrade. +parent_pid=$$ +parent_fp="$(parent_identity "$parent_pid")" + +( + trap '' INT TERM HUP + while :; do + now_fp="$(parent_identity "$parent_pid")" + if [ -z "$now_fp" ] || [ "$now_fp" != "$parent_fp" ]; then + # Wrapper is gone (or its PID was recycled). Reap and exit. + cleanup + exit 0 + fi + sleep 0.2 + done +) & +watchdog_pid=$! +disown "$watchdog_pid" 2>/dev/null || true + +# `wait` returns the child's exit code, OR 128+signal if interrupted. +# Disable -e around it so a non-zero claude exit doesn't trip our own +# EXIT trap before we capture the code. +set +e +wait "$child_pid" +exit_code=$? +set -e + +# Graceful path: the wrapper is still alive here, so the watchdog is still +# in its poll loop and has NOT reaped anything. Take it down before it can +# fire a redundant pass. It deliberately ignores catchable signals, so +# only SIGKILL reliably stops it from here -- safe precisely because at +# this point it has done nothing that needs unwinding. +trap - EXIT +kill -KILL "$watchdog_pid" 2>/dev/null || true +cleanup # one explicit idempotent pass (reaps any lingering grandchild) +exit "$exit_code" diff --git a/tools/macos/find-claude.sh b/tools/macos/find-claude.sh new file mode 100644 index 0000000..c969e6e --- /dev/null +++ b/tools/macos/find-claude.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# find-claude.sh -- locate the claude binary across common macOS install layouts. +# +# Prints the resolved path on stdout. Exits 0 on success, 127 if not found. +# bash 3.2-safe (stock macOS /bin/bash is the frozen 3.2.57); no features +# newer than 3.2, no external deps beyond the BSD coreutils macOS ships. +# +# Probe order (first hit wins) -- mirrors tools/linux/find-claude.sh so the +# two platforms keep an identical discovery contract: +# 1. command -v claude # PATH +# 2. $(npm config get prefix)/bin/claude +# 3. /opt/homebrew/bin/claude # Apple Silicon Homebrew (the common case) +# 4. /usr/local/bin/claude # Intel Homebrew + classic /usr/local +# 5. ~/.nvm/versions/node/*/bin/claude (latest version) +# 6. fnm default alias (XDG path AND macOS "Library/Application Support") +# 7. asdf which claude +# 8. volta which claude +# 9. ~/.config/yarn/global/node_modules/.bin/claude +# +# Sourceable: defines find_claude(); callers can source and reuse. +# Executable: runs find_claude and prints the result. + +set -euo pipefail + +find_claude() { + # 1. PATH + if command -v claude >/dev/null 2>&1; then + command -v claude + return 0 + fi + + # 2. npm global prefix + if command -v npm >/dev/null 2>&1; then + local npm_prefix + npm_prefix="$(npm config get prefix 2>/dev/null || true)" + if [ -n "${npm_prefix:-}" ] && [ -x "$npm_prefix/bin/claude" ]; then + printf '%s\n' "$npm_prefix/bin/claude" + return 0 + fi + fi + + # 3-4. Homebrew (Apple Silicon first, then Intel/classic /usr/local). + # On macOS this is the dominant install path, so it sits high. + local p + for p in /opt/homebrew/bin/claude /usr/local/bin/claude; do + if [ -x "$p" ]; then + printf '%s\n' "$p" + return 0 + fi + done + + # 5. nvm -- pick the highest-version dir's claude + if [ -d "${HOME:-/dev/null}/.nvm/versions/node" ]; then + local nvm_pick + nvm_pick="$(ls -1 "$HOME/.nvm/versions/node" 2>/dev/null | sort -V | tail -1 || true)" + if [ -n "${nvm_pick:-}" ] && [ -x "$HOME/.nvm/versions/node/$nvm_pick/bin/claude" ]; then + printf '%s\n' "$HOME/.nvm/versions/node/$nvm_pick/bin/claude" + return 0 + fi + fi + + # 6. fnm. Linux defaults FNM_DIR to ~/.local/share/fnm; macOS defaults it + # to ~/Library/Application Support/fnm. Probe both so a single contract + # covers either layout (this is the one real macOS-vs-Linux divergence). + local fnm_default + for fnm_default in \ + "${HOME:-}/.local/share/fnm/aliases/default/bin/claude" \ + "${HOME:-}/Library/Application Support/fnm/aliases/default/bin/claude"; do + if [ -x "$fnm_default" ]; then + printf '%s\n' "$fnm_default" + return 0 + fi + done + + # 7. asdf + if command -v asdf >/dev/null 2>&1; then + local asdf_hit + asdf_hit="$(asdf which claude 2>/dev/null || true)" + if [ -n "${asdf_hit:-}" ] && [ -x "$asdf_hit" ]; then + printf '%s\n' "$asdf_hit" + return 0 + fi + fi + + # 8. volta + if command -v volta >/dev/null 2>&1; then + local volta_hit + volta_hit="$(volta which claude 2>/dev/null || true)" + if [ -n "${volta_hit:-}" ] && [ -x "$volta_hit" ]; then + printf '%s\n' "$volta_hit" + return 0 + fi + fi + + # 9. yarn global + if [ -x "${HOME:-}/.config/yarn/global/node_modules/.bin/claude" ]; then + printf '%s\n' "$HOME/.config/yarn/global/node_modules/.bin/claude" + return 0 + fi + + return 127 +} + +# Run as script -- print the result. Source-safe: skip when sourced. +if [ "${BASH_SOURCE[0]:-}" = "${0}" ]; then + if path="$(find_claude)"; then + printf '%s\n' "$path" + else + printf 'find-claude: claude not found in PATH or any known install location\n' >&2 + exit 127 + fi +fi