From 14b9c32b0e6ece3be173a454099fafc7f7f3a893 Mon Sep 17 00:00:00 2001 From: Ronil Basu Date: Sat, 16 May 2026 15:54:04 -0500 Subject: [PATCH 01/13] feat(macos): find-claude + claude-jobbed runtime pair (MEDIUM tier) macOS has none of the three strong primitives the other ports use (no Job Object, no cgroup.kill, no PR_SET_PDEATHSIG) and no /proc. A plain setpgid+trap wrapper is only WEAK there: bash traps never fire on SIGKILL, so Activity Monitor "Force Quit" of the wrapper orphans the tree. Port the out-of-process watchdog from the Linux STRONG path (cfe7479) to reach MEDIUM: a disowned subshell polls the wrapper's identity and reaps the child process group the moment the wrapper vanishes. Being a separate process, it survives a SIGKILL aimed at the wrapper alone -- the WEAK -> MEDIUM upgrade. Two macOS-specific adaptations from the Linux template: - parent identity is `ps -p $pid -o lstart=` (no /proc/$$ mtime); trimmed identically on both sides so the compare can't skew. - bash 3.2-safe throughout: stock /usr/bin/env bash is Apple's frozen 3.2.57. No arrays, no [[ =~ ]], no ${x,,}, no mapfile. The watchdog reuses cleanup() (inherited by the subshell fork) so the in-process and watchdog kill paths cannot drift. `set -m` is load-bearing twice: the child gets its own pgid (killable as -$child_pgid without hitting the wrapper) and the watchdog lands in a different pgid (so the group kill can't take it down). find-claude.sh mirrors the Linux 9-probe contract exactly; the one divergence is probe 6 checking both fnm layouts (~/.local/share on Linux, ~/Library/Application Support on macOS). Honest ceiling: a simultaneous kill -9 of BOTH wrapper and watchdog still leaks -- macOS has no kernel primitive to cover that. MEDIUM, not STRONG. tests/macos/test-honesty.bats will pin this. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/macos/claude-jobbed.sh | 128 +++++++++++++++++++++++++++++++++++ tools/macos/find-claude.sh | 112 ++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 tools/macos/claude-jobbed.sh create mode 100644 tools/macos/find-claude.sh 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 From 67df1b6492c2de406c3d46acf264f56f20a00d7f Mon Sep 17 00:00:00 2001 From: Ronil Basu Date: Sat, 16 May 2026 15:54:46 -0500 Subject: [PATCH 02/13] feat(cmd): cmd.exe shim into the PowerShell Job Object wrapper cmd.exe has no $PROFILE and cannot call the Win32 API, so it can neither auto-shadow `claude` nor host a Job Object itself. Rather than ship a weaker cmd-native tier, re-exec into PowerShell running the existing claude-jobbed.ps1 so a cmd.exe user gets the IDENTICAL STRONG KILL_ON_JOB_CLOSE guarantee with zero second code path to keep in sync. %~dp0 resolves the sibling .ps1 regardless of caller cwd; the -NoProfile -ExecutionPolicy Bypass -File invocation matches the v1.0.3 SpawnPlan .ps1 host-routing convention; exit /b %ERRORLEVEL% propagates the real exit code. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/claude-jobbed.cmd | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tools/claude-jobbed.cmd 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% From cf5c228e31367693ea8a29aef5ce6148195e84f5 Mon Sep 17 00:00:00 2001 From: Ronil Basu Date: Sat, 16 May 2026 16:03:44 -0500 Subject: [PATCH 03/13] fix(install): portable mktemp template for BSD/macOS inject_into and uninstall_from both created their scratch file with a bare `mktemp`. GNU coreutils mktemp defaults a template when given no argument, so this worked on Linux. BSD mktemp (macOS) requires an explicit template and exits non-zero with a usage error otherwise -- which would break --force reinstall and --uninstall the moment the Darwin branch (next commit) lets those code paths run on a Mac. Use mktemp "${TMPDIR:-/tmp}/csc-block.XXXXXX": GNU accepts the explicit template too, so this is a strict portability improvement with zero behavior change on Linux. Co-Authored-By: Claude Opus 4.7 (1M context) --- install.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index f338e07..649bc73 100644 --- a/install.sh +++ b/install.sh @@ -85,7 +85,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 +109,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 } From e3bc316c30dec4929ce7ca51932b0d94c38c6bb7 Mon Sep 17 00:00:00 2001 From: Ronil Basu Date: Sat, 16 May 2026 16:04:17 -0500 Subject: [PATCH 04/13] feat(install): real macOS Darwin branch with honest MEDIUM guarantee Darwin previously exited 1 with a "v1.2.0 roadmap" message. Replace it with a working install path: - platform=macos; the guarantee string states the MEDIUM tier in full -- setpgid + out-of-process watchdog, survives Force-Quit / kill -9 of the wrapper alone, NOT survivable if wrapper AND watchdog are kill -9'd simultaneously. macOS has no cgroup.kill or Job Object equivalent; that is the honest ceiling, stated as such, not a TODO. - inject_all / uninstall_all helpers as a single source of truth so the install and uninstall rc-file target lists can never drift. macOS Terminal.app runs bash as a LOGIN shell (reads ~/.bash_profile, not ~/.bashrc), so macOS additionally targets ~/.bash_profile. - platform-aware rc_human banner so the pre-flight ack lists the files actually touched on this OS. Pairs with tools/macos/{find-claude,claude-jobbed}.sh (14b9c32) and the cmd.exe shim (67df1b6). Depends on the portable mktemp from cf5c228 so --force and --uninstall work on BSD/macOS. Co-Authored-By: Claude Opus 4.7 (1M context) --- install.sh | 50 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/install.sh b/install.sh index 649bc73..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 @@ -120,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 < Date: Sat, 16 May 2026 16:16:11 -0500 Subject: [PATCH 05/13] test(macos): port find-claude + pgid-cleanup + installer parity suites Faithful mirrors of the green tests/linux/*.bats, adapted for the three REAL macOS divergences (not cosmetic forks): - find-claude: probe 6 must check BOTH fnm layouts -- the XDG ~/.local/share/fnm AND the macOS-default "~/Library/Application Support/fnm". Added a dedicated 6b test the Linux suite has no reason to carry. - pgid-cleanup: NO CLAUDE_JOBBED_FORCE_FALLBACK toggle exists on macOS (one setpgid + trap + out-of-process watchdog path, always armed), so the suite drives that single path instead of the Linux dual-path. - installer: ~/.bash_profile IS targeted (Terminal.app runs bash as a LOGIN shell) and the banner must state the honest MEDIUM ceiling in words -- both asserted, neither meaningful on Linux. All three skip with an honest reason off-macOS (BSD mktemp templates, Darwin-only install.sh arm) rather than a green that proves nothing. 20 bats across these 3 suites; the load-bearing negative test (test-honesty.bats) lands next, isolated for git-log visibility. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/macos/test-find-claude.bats | 175 +++++++++++++++++++++++++++++ tests/macos/test-installer.bats | 133 ++++++++++++++++++++++ tests/macos/test-pgid-cleanup.bats | 101 +++++++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 tests/macos/test-find-claude.bats create mode 100644 tests/macos/test-installer.bats create mode 100644 tests/macos/test-pgid-cleanup.bats diff --git a/tests/macos/test-find-claude.bats b/tests/macos/test-find-claude.bats new file mode 100644 index 0000000..aa98f30 --- /dev/null +++ b/tests/macos/test-find-claude.bats @@ -0,0 +1,175 @@ +#!/usr/bin/env bats +# Unit tests for tools/macos/find-claude.sh probe ordering. +# +# Faithful mirror of tests/linux/test-find-claude.bats: the two platforms +# share a discovery contract, so the probe-priority assertions are the same +# shape. The ONE intentional divergence is probe 6 (fnm): macOS defaults +# FNM_DIR to ~/Library/Application Support/fnm rather than the XDG +# ~/.local/share/fnm, and find-claude.sh probes BOTH. We add a dedicated +# test for the Library layout that the Linux suite has no reason to carry. +# +# Pure-bats. Sandboxes PATH and HOME so probes hit only paths we control. +# Probes 3-4 (Homebrew /opt/homebrew, /usr/local) are absolute paths that +# cannot be redirected via HOME/PATH; they are exercised only when the +# Homebrew bin dir is actually writable (rare without sudo on a CI runner), +# otherwise skipped with an honest reason rather than a silent pass. + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + FIND="$REPO_ROOT/tools/macos/find-claude.sh" + [ -x "$FIND" ] || chmod +x "$FIND" + + SANDBOX="$(mktemp -d "${TMPDIR:-/tmp}/csc-find.XXXXXX")" + ORIG_PATH="$PATH" + ORIG_HOME="$HOME" + export HOME="$SANDBOX/home" + mkdir -p "$HOME" + # Strip system claude (if any) -- only what we add inside SANDBOX shows up. + # /usr/bin and /bin needed for sort, ls, grep used inside the script. + export PATH="/usr/bin:/bin" +} + +teardown() { + export PATH="$ORIG_PATH" + export HOME="$ORIG_HOME" + rm -rf "$SANDBOX" +} + +make_fake_claude() { + local target="$1" + mkdir -p "$(dirname "$target")" + printf '#!/bin/sh\necho "fake claude at $0"\n' > "$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-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" ] +} From 7fb292cf749eaf4de77c744bbf8ef1355712c2da Mon Sep 17 00:00:00 2001 From: Ronil Basu Date: Sat, 16 May 2026 16:16:11 -0500 Subject: [PATCH 06/13] test(macos): test-honesty.bats pins the honest MEDIUM ceiling Isolated in its own commit on purpose: this is the load-bearing NEGATIVE test and `git log` should make that visible. It is the only file in the suite that asserts a leak STILL happens. CASE 1 (proves we beat WEAK): SIGKILL of the wrapper ALONE -- the disowned out-of-process watchdog notices and reaps the child group. A plain setpgid+trap wrapper would leak here (bash traps never fire on SIGKILL). Asserts the grandchild DIES. CASE 2 (pins the ceiling, does NOT claim STRONG): a SIMULTANEOUS SIGKILL of wrapper AND watchdog leaks the tree. macOS has no cgroup.kill / Job Object. Asserts the grandchild SURVIVES. If someone "fixes" CASE 2 into a leak-free pass they either added a real kernel primitive (update the guarantee) or wrote something that does not hold -- the test comment says exactly that, so the diff must explain itself. The install banner and DESIGN.md state this ceiling in prose; this test is what keeps those words true. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/macos/test-honesty.bats | 183 ++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/macos/test-honesty.bats 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. +} From a2710713072f52d964e5e868568d6714783c3aad Mon Sep 17 00:00:00 2001 From: Ronil Basu Date: Sat, 16 May 2026 16:16:11 -0500 Subject: [PATCH 07/13] fix(tests): correct stale "macOS lands in v1.2.0" Linux skip reason tests/linux/test-installer.bats skipped off-Linux with the message "install.sh is Linux-only in v1.1.0 -- macOS lands in v1.2.0". macOS support ships in THIS branch, so that string now lies about project state to anyone reading a skipped-test report or the source. Guard logic is deliberately unchanged and still correct: these assertions encode the LINUX rc-file contract (targets ~/.bashrc + ~/.zshrc, deliberately never ~/.bash_profile -- the macOS path differs and has its own suite). Only the comment and skip message are corrected, now pointing at tests/macos/test-installer.bats. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/linux/test-installer.bats | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/linux/test-installer.bats b/tests/linux/test-installer.bats index bcca397..f345e33 100644 --- a/tests/linux/test-installer.bats +++ b/tests/linux/test-installer.bats @@ -16,10 +16,13 @@ setup() { touch "$HOME/.zshrc" # leave fish unset to verify "skip when absent" behavior - # Skip the entire suite if we're not on Linux -- install.sh refuses - # to run on Darwin (v1.2 milestone) or unknown OSes. + # These assertions encode the LINUX rc-file contract specifically: + # the Linux installer targets ~/.bashrc + ~/.zshrc and deliberately + # never writes ~/.bash_profile. macOS is now supported too, but its + # path targets ~/.bash_profile as well and is covered by its own + # suite, so off-Linux this file skips rather than mis-asserting. if [ "$(uname -s)" != "Linux" ]; then - skip "install.sh is Linux-only in v1.1.0 -- macOS lands in v1.2.0" + skip "Linux rc-file contract; macOS path covered by tests/macos/test-installer.bats" fi } From 67823df624e9f25839e7f6865444e568f24b088c Mon Sep 17 00:00:00 2001 From: Ronil Basu Date: Sat, 16 May 2026 16:18:13 -0500 Subject: [PATCH 08/13] ci(macos): add macos-14 + macos-13 bats legs Both architectures on purpose -- macos-14 is Apple Silicon (arm64), macos-13 is Intel (x86_64). The reaped surface is process-group + signal + ps behavior, which can differ by arch; fail-fast off so an arch-only regression still surfaces the other arch's result. Mirrors the linux job's "probe -> verify environment honestly -> run suite" shape, plus a macOS-specific wrinkle: GitHub runners put a modern Homebrew bash ahead of Apple's frozen /bin/bash 3.2.57 on PATH, so the functional bats suite may NOT exercise the 3.2 constraint the wrapper is written for. Rather than hide that, a dedicated step parses the runtime scripts under `/bin/bash -n` (Apple stock 3.2.57) and the probe logs which bash runs the suite. Same "never silently pass" ethos as the linux job's systemd --user gate. bats-core (not the dead pre-1.0 `bats` formula) via brew. on.push.branches already carries macos-port; no trigger change needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78387f0..0139515 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,6 +49,57 @@ 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) ---- + # Matrix covers BOTH architectures on purpose: macos-14 is Apple Silicon + # (arm64), macos-13 is Intel (x86_64). Process-group / signal / ps + # behavior is the load-bearing surface here and is exactly the kind of + # thing that can differ across arch, so fail-fast is off -- an arch-only + # regression must still surface the other arch's result. + macos: + name: bats (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-14, macos-13] + 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) From 2e477c8ad56f5ed146a207ee09a8c8bd351045f1 Mon Sep 17 00:00:00 2001 From: Ronil Basu Date: Sat, 16 May 2026 16:31:15 -0500 Subject: [PATCH 09/13] docs(readme): cross-platform sweep for macOS MEDIUM + cmd.exe shim The README still described macOS as "WEAK -- planned", cmd.exe as "not supported", and carried a "Help on macOS yet" non-goal -- all false now that v1.2.0 ships the macOS MEDIUM tier (tools/macos/) and the tools/claude-jobbed.cmd delegating shim. Stale aspirational language in a shipped recruiting artifact reads as neglect; replaced it with a precisely-bounded MEDIUM claim that names its own pinning test (tests/macos/test-honesty.bats). Sweep covers: Platform/Tests badges (40 bats), guarantee-matrix macOS row WEAK->MEDIUM with the honest simultaneous-SIGKILL ceiling, IMPORTANT block (cmd.exe now supported via shim, not "PowerShell only"), Linux->Linux/macOS install section with the .bash_profile login-shell rationale, Components cmd + macOS tables, a new "How it works / macOS" walkthrough, the macOS bats table, and the CI section with the honest Homebrew-bash-shadows-/bin/bash gap note. Verify-before-done also caught three cross-file drift items not in the original edit plan: Requirements had no macOS bullet (only Windows/Linux), test-find-claude.bats still said Homebrew probes "land in v1.2.0 macOS CI" (now shipped, not future), and "either platform" implied two when there are three. Fixed all three. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8f2ad13..1045db3 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-13/macos-14 legs.) | | `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) and `macos-13` (Intel) for the macOS bats suite, and `windows-latest` for the PowerShell suite. The macOS legs include 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 From 64e2b718fd1e97f44d938511bfdf986dd2a462c6 Mon Sep 17 00:00:00 2001 From: Ronil Basu Date: Sat, 16 May 2026 16:39:51 -0500 Subject: [PATCH 10/13] docs(design): correct false macOS non-goal, add guarantee matrix DESIGN.md was frozen at "Version: 1.0.0" and predated both the Linux v1.1.0 and macOS v1.2.0 ports. Most of it was merely stale, but line 26 was actively FALSE: it listed "Running on macOS or Linux" as a non-goal on the claim that those platforms "already have OS-level reapers ... equivalent semantics on macOS." That is the exact inverse of reality -- macOS has no Job Object, no cgroup.kill, and no PR_SET_PDEATHSIG, and that absence is the entire reason the macOS tier tops out at MEDIUM. In a recruiting artifact a stale line reads as neglect; a confidently-wrong technical claim reads as not understanding your own system, which is worse. Replaced it with an honest macOS-STRONG-gap non-goal and added a "> Note:" paragraph that explicitly owns the prior error (self-auditing spec is a credibility signal, not a liability). Also in this sweep: - Header -> Version 1.2.0 + Status line (Windows STRONG / Linux STRONG+MEDIUM / macOS MEDIUM + cmd.exe) + Revised 2026-05-16. - Problem statement generalized to the cross-platform process chain (Windows cmd.exe->npx.cmd->node vs POSIX sh->npx->node). - New "Cross-platform guarantee matrix" section + "Why macOS is MEDIUM, not STRONG" rationale (setpgid + trap + disowned out-of-process watchdog; honest simultaneous-SIGKILL ceiling; Linux v1.1.0 watchdog-architecture reuse). - Verification: added test-spawn-plan.ps1 (item 4) and the bats suites (item 5), naming test-cgroup-kill.bats and test-honesty.bats as the two load-bearing negative tests; status line -> 36+1 pwsh + 40 bats, full CI matrix. - WSL open question struck through (resolved in v1.1.0). - Versioning section rewritten to the actual shipped releases (v1.0.0 / v1.0.2-3 / v1.1.0 / v1.2.0 / Deferred). Architecture / Why-Job-Objects / Component-contracts bodies left unchanged on purpose -- still accurate for Windows, and a pre-release rewrite of correct prose is needless risk (Rule #9). Commit 2 of 3 in the v1.2.0 docs sweep (1: README 2e477c8). Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN.md | 48 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 6a9f5d2..afb2419 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,25 @@ 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 the full matrix — `ubuntu-latest`, `macos-13`, `macos-14`, `windows-latest` — on every push; see `.github/workflows/test.yml`. (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. - 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 `macos-13` (Intel) + `macos-14` (Apple Silicon) legs plus a `/bin/bash -n` static-parse step against Apple's stock 3.2.57. +- 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 From e331410cedd1f2ba9563ce269b7b71101034ef37 Mon Sep 17 00:00:00 2001 From: Ronil Basu Date: Sat, 16 May 2026 16:50:58 -0500 Subject: [PATCH 11/13] docs(skill): cross-platform triggers + rebuild stale File Map The skill's `description` is the classifier input that decides whether the skill loads at all -- and it was Windows-scoped ("structured concurrency on Windows", Win32-only triggers, no macOS/Linux/cmd terms). That is the precise mechanism by which a Mac user's "Claude Code keeps leaking node processes on my mac" never reached this skill: the macOS code/tests/CI shipped in v1.2.0 are useless if retrieval never surfaces them. Rewrote the description cross-platform and deliberately pushy against undertriggering (explicit Mac/cmd.exe/Linux phrasings, "activity monitor full of node"), since undertriggering for Mac users is the exact bug v1.2.0 exists to fix. Also de-Windowsed the title; rewrote the Overview's Linux-and-Windows-only-primitive passage (it implied only Windows was wired up and omitted that macOS has NO kernel primitive -- the SKILL.md analogue of the false DESIGN.md line-26) into the honest per-platform STRONG/MEDIUM guarantee, naming test-honesty.bats as the macOS-ceiling pin; tagged the Diagnostic/Cleanup layers Windows-only (only the prevention wrapper + find-claude were ported, per the actual code) and made Prevention the named cross-platform layer; added a Platform dispatch block (cmd.exe shim, install.sh, POSIX wrappers); fixed the verify step (4 PS suites + the bats matrix, not 3 PS files); and added a Rule #1 honesty note that macOS/Linux are CI-validated, NOT locally micro-benched (no synthetic latency numbers claimed for the POSIX tiers). REBUILT the File Map from `git ls-files` ground truth -- the old map omitted install.sh, the entire tools/linux + tools/macos trees, claude-jobbed.cmd, lib/SpawnPlan.ps1, all 8 bats files, test-spawn-plan.ps1, SECURITY.md, docs/FAQ.md, and .github/workflows. A File Map that does not match `git ls-files` is exactly what a senior reviewer greps to spot-check rigor in a recruiting artifact. Verify-before-done (re-read full file, same pass that caught 3 README drifts) caught one cross-file drift NOT in the edit plan: the "When to Use" trigger list -- a GATING section -- was still Windows-only symptoms (Task Manager / Windows Command Processor / node.exe), so a Mac user who did reach the body would conclude it is Windows-only and undo the description fix. Generalized it to Activity Monitor / `ps`/`htop` / PID-1 reparented `node`. Commit 3 of 3 in the v1.2.0 docs sweep (1: README 2e477c8, 2: DESIGN 64e2b71). SKILL.md house style is ASCII `--`/`->`/`>=`, matched (distinct from README/DESIGN em-dash). Co-Authored-By: Claude Opus 4.7 (1M context) --- SKILL.md | 99 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 71 insertions(+), 28 deletions(-) diff --git a/SKILL.md b/SKILL.md index fbbeeae..eae6e9d 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-13` / `macos-14` legs (bats is not on Windows). 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-13, 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-13` / `macos-14` / `ubuntu-latest` legs), 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. From 290de9683e0ce9bde8d185a3b8b261aea4896e6a Mon Sep 17 00:00:00 2001 From: Ronil Basu Date: Mon, 18 May 2026 04:58:39 +0200 Subject: [PATCH 12/13] ci(macos): drop unschedulable hosted Intel macos-13 leg (#3) The macos-13 matrix leg never once received a runner. Both runs that included it (push 25973920036, PR 25973931445) sat in "awaiting a runner" until GitHub's hard 24h account ceiling and were auto-cancelled, keeping the `tests` check permanently non-green and blocking PR #2. Root cause is not code. macos-14 -- same job definition, only the matrix os value differs -- is green in ~25s, as are ubuntu-latest and windows-latest. GitHub is retiring hosted Intel macOS, so macos-13 is unschedulable for this account. Critically, `timeout-minutes: 15` bounds execution time AFTER a runner is assigned and does nothing for queue starvation -- which is why a "15-minute" job died at 24h, not 15 min. No workflow knob can cap "awaiting a runner". Matrix reduced to [macos-14]. Kept the strategy/matrix mechanism single-entry (not collapsed to a bare runs-on) so re-adding an Intel leg later is a one-line change and the green check keeps its `bats (macos-14)` name. The scripts are architecture-neutral and bash-3.2-safe by construction; the static-parse step still proves the 3.2 syntax constraint, so only x86_64 execution is uncovered. Intel execution coverage is tracked as an explicit open gap, not silently abandoned: #3. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0139515..03b512c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,18 +50,26 @@ jobs: bats --print-output-on-failure tests/linux/ # ----- macOS: bats suite for tools/macos/ + install.sh (MEDIUM tier) ---- - # Matrix covers BOTH architectures on purpose: macos-14 is Apple Silicon - # (arm64), macos-13 is Intel (x86_64). Process-group / signal / ps - # behavior is the load-bearing surface here and is exactly the kind of - # thing that can differ across arch, so fail-fast is off -- an arch-only - # regression must still surface the other arch's result. + # 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, macos-13] + os: [macos-14] timeout-minutes: 15 steps: - uses: actions/checkout@v4 From 521fc43d6ba5821a83086896c87f22e6e1ef5d31 Mon Sep 17 00:00:00 2001 From: Ronil Basu Date: Mon, 18 May 2026 05:02:49 +0200 Subject: [PATCH 13/13] docs: record dropped Intel macos-13 leg as a tracked open gap (#3) Reconciles every macos-13 reference across README, DESIGN, and SKILL to match the matrix change in 290de96 -- in lockstep, so the three surfaces never disagree mid-history. - README: macOS CI is the macos-14 leg; an explicit clause states Intel is intentionally not a CI leg (GitHub hosted-Intel retirement, 24h await-runner) with a link to #3; "macOS legs" -> "macOS leg". - DESIGN: the CI-matrix line and the v1.2.0 Versioning bullet drop macos-13 and name #3; a new "Open questions (deferred)" bullet tracks Intel execution coverage as an open gap, next to the WSL entry -- the same honest-open-gap posture chosen over silent removal. - SKILL: the verify-step leg list, the File Map CI-matrix annotation, and the perf-validation line all drop macos-13 and point at #3. Test COUNTS are unchanged (22 macOS bats still run, on macos-14) -- only the matrix-leg enumeration moved. Honest framing throughout: the scripts are arch-neutral and bash-3.2-safe by construction and the static-parse step still proves the 3.2 syntax, so only x86_64 execution is uncovered, not syntax. Co-Authored-By: Claude Opus 4.7 (1M context) --- DESIGN.md | 5 +++-- README.md | 4 ++-- SKILL.md | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index afb2419..f27a50d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -155,11 +155,12 @@ Tests in `tests/`: 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 suites must pass before any release tag. **36+1 PowerShell + 40 bats (18 Linux + 22 macOS) passing as of v1.2.0.** CI runs the full matrix — `ubuntu-latest`, `macos-13`, `macos-14`, `windows-latest` — on every push; see `.github/workflows/test.yml`. (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.) +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?~~ 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. @@ -169,7 +170,7 @@ All suites must pass before any release tag. **36+1 PowerShell + 40 bats (18 Lin - 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 `macos-13` (Intel) + `macos-14` (Apple Silicon) legs plus a `/bin/bash -n` static-parse step against Apple's stock 3.2.57. +- 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 1045db3..b72c8de 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,7 @@ 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 are exercised by the macOS suite on the macos-13/macos-14 legs.) | +| `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. | @@ -306,7 +306,7 @@ bats --print-output-on-failure tests/linux/ bats --print-output-on-failure tests/macos/ ``` -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) and `macos-13` (Intel) for the macOS bats suite, and `windows-latest` for the PowerShell suite. The macOS legs include 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`. +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 diff --git a/SKILL.md b/SKILL.md index eae6e9d..218b8d3 100644 --- a/SKILL.md +++ b/SKILL.md @@ -59,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 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-13` / `macos-14` legs (bats is not on Windows). 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. @@ -122,7 +122,7 @@ Tree below mirrors `git ls-files` exactly: | +-- architecture.svg (diagram) | +-- demo.mp4 (screen capture) +-- .github/workflows/ - +-- test.yml (CI matrix: ubuntu-latest, macos-13, macos-14, windows-latest) + +-- test.yml (CI matrix: ubuntu-latest, macos-14, windows-latest) User-side state (NOT in repo, created by install-reap.ps1 / install.sh): ~/.reap/ @@ -163,4 +163,4 @@ Post-skill: - 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-13` / `macos-14` / `ubuntu-latest` legs), 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. +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.