diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 3267e922..25084c38 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -23,6 +23,8 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-ps-portable.sh +. "$SCRIPT_DIR/fm-ps-portable.sh" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" @@ -38,7 +40,7 @@ detect_own() { # Layer 2: walk the parent chain and match the command name. local pid=$$ comm args for _ in 1 2 3 4 5 6 7 8; do - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break + comm=$(fm_ps_field comm "$pid") || break case "$(basename "$comm")" in *claude*) echo claude; return ;; *codex*) echo codex; return ;; @@ -47,7 +49,7 @@ detect_own() { pi) echo pi; return ;; node*|python*) # Bare interpreter: match the harness name in its script path. - args=$(ps -o args= -p "$pid" 2>/dev/null) + args=$(fm_ps_field args "$pid") case "$args" in *claude*) echo claude; return ;; *codex*) echo codex; return ;; @@ -56,7 +58,7 @@ detect_own() { *" pi "*|*/pi) echo pi; return ;; esac ;; esac - pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') + pid=$(fm_ps_field ppid "$pid") if [ -z "$pid" ] || [ "$pid" -le 1 ]; then break fi diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 33e4b0d2..569d006c 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -8,6 +8,8 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-ps-portable.sh +. "$SCRIPT_DIR/fm-ps-portable.sh" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" @@ -20,8 +22,8 @@ HARNESS_RE='claude|codex|opencode|grok|^pi$' harness_pid() { local pid=$$ comm args for _ in 1 2 3 4 5 6 7 8; do - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - args=$(ps -o args= -p "$pid" 2>/dev/null) + comm=$(fm_ps_field comm "$pid") || return 1 + args=$(fm_ps_field args "$pid") if printf '%s' "$(basename "$comm")" | grep -qE "$HARNESS_RE"; then echo "$pid"; return 0 fi @@ -29,7 +31,7 @@ harness_pid() { case "$comm" in *node*|*python*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { echo "$pid"; return 0; } ;; esac - pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') + pid=$(fm_ps_field ppid "$pid") [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1 done return 1 @@ -38,8 +40,8 @@ harness_pid() { holder_alive() { # true if $1 is a live process that looks like a harness local pid=$1 comm kill -0 "$pid" 2>/dev/null || return 1 - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - printf '%s' "$(basename "$comm") $(ps -o args= -p "$pid" 2>/dev/null)" | grep -qE "$HARNESS_RE" + comm=$(fm_ps_field comm "$pid") || return 1 + printf '%s' "$(basename "$comm") $(fm_ps_field args "$pid")" | grep -qE "$HARNESS_RE" } if [ "${1:-}" = "status" ]; then diff --git a/bin/fm-ps-portable.sh b/bin/fm-ps-portable.sh new file mode 100644 index 00000000..21045d6e --- /dev/null +++ b/bin/fm-ps-portable.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# fm-ps-portable.sh - portable process introspection for firstmate. +# +# Emulates the `ps -o = -p ` queries firstmate uses to walk the +# harness process ancestry (fm-lock.sh, fm-harness.sh) and to fingerprint a +# pid for reuse-detection (fm-wake-lib.sh). MSYS2 / Git Bash on Windows ships +# a `ps` with no `-o` flag - it errors "ps: unknown option -- o" - so those +# walks break there as written. +# +# On macOS/Linux the native `ps -o` path is used unchanged (exact, fast). Only +# where `ps -o` is unavailable do we fall back to parsing `ps -f`, which MSYS +# supports and which prints the full command line WITH arguments: +# UID PID PPID TTY STIME COMMAND... +# This mirrors the existing platform-branch convention in fm-wake-lib.sh +# (`stat -f` on Darwin vs `stat -c` elsewhere). +# +# Sourced, never executed. Sets no global shell options. + +# Capability probe, run once and cached (idempotent across multiple `source`s). +if [ -z "${FM_PS_NATIVE:-}" ]; then + if ps -o comm= -p "$$" >/dev/null 2>&1; then + FM_PS_NATIVE=1 + else + FM_PS_NATIVE=0 + fi +fi + +# fm_ps_field +# comm - basename of argv[0] (handles / and \ so Windows node.exe resolves) +# args - full command line +# ppid - parent pid (whitespace stripped) +# identity - a stable "start-time command" fingerprint for pid-reuse detection +# Prints the field to stdout; returns non-zero when the pid is not found. +fm_ps_field() { + local field=$1 pid=$2 + if [ "${FM_PS_NATIVE:-1}" = 1 ]; then + case "$field" in + comm) ps -o comm= -p "$pid" 2>/dev/null ;; + args) ps -o args= -p "$pid" 2>/dev/null ;; + ppid) ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ' ;; + identity) ps -p "$pid" -o lstart= -o command= 2>/dev/null | sed 's/^[[:space:]]*//' ;; + esac + return + fi + # MSYS fallback: parse one `ps -f` row. Columns: UID PID PPID TTY STIME COMMAND... + local row ppid stime cmd first + row=$(ps -f 2>/dev/null | awk -v want="$pid" 'NR>1 && $2==want { + p=$3; s=$5; c=""; for (i=6; i<=NF; i++) c = c (i>6 ? " " : "") $i; + print p "\t" s "\t" c; exit }') + [ -n "$row" ] || return 1 + ppid=${row%%$'\t'*}; row=${row#*$'\t'} + stime=${row%%$'\t'*}; cmd=${row#*$'\t'} + case "$field" in + ppid) printf '%s' "$ppid" ;; + args) printf '%s' "$cmd" ;; + comm) first=${cmd%%[[:space:]]*}; printf '%s' "${first##*[\\/]}" ;; + identity) printf '%s %s' "$stime" "$cmd" ;; + esac +} diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index af2112a6..1e9bfcf2 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -2,6 +2,8 @@ # Shared durable wake queue and portable lock helpers. FM_WAKE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-ps-portable.sh +. "$FM_WAKE_LIB_DIR/fm-ps-portable.sh" FM_WAKE_DEFAULT_ROOT="$(cd "$FM_WAKE_LIB_DIR/.." && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_WAKE_DEFAULT_ROOT}}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" @@ -28,7 +30,7 @@ fm_pid_identity() { case "$pid" in ''|*[!0-9]*) return 1 ;; esac - out=$(ps -p "$pid" -o lstart= -o command= 2>/dev/null) || return 1 + out=$(fm_ps_field identity "$pid") || return 1 [ -n "$out" ] || return 1 printf '%s\n' "$out" | sed 's/^[[:space:]]*//' } diff --git a/tests/fm-ps-portable.test.sh b/tests/fm-ps-portable.test.sh new file mode 100644 index 00000000..4066ded6 --- /dev/null +++ b/tests/fm-ps-portable.test.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-ps-portable.sh - the portable process-introspection +# shim that keeps the harness-ancestry walk (fm-lock.sh, fm-harness.sh) and the +# pid fingerprint (fm-wake-lib.sh) working on platforms whose `ps` lacks the +# `-o` flag (MSYS2 / Git Bash on Windows), with byte-identical behavior on +# macOS/Linux via the native `ps -o` path. +# +# The assertions are platform-neutral: each must hold on BOTH the native +# `ps -o` path and the MSYS `ps -f` fallback. Nothing here mocks ps; the shim +# is exercised against this live test process ($$). +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck source=bin/fm-ps-portable.sh +. "$ROOT/bin/fm-ps-portable.sh" + +test_native_flag_is_boolean() { + case "${FM_PS_NATIVE:-unset}" in + 0|1) pass "FM_PS_NATIVE resolved to a boolean ($FM_PS_NATIVE)" ;; + *) fail "FM_PS_NATIVE should be 0 or 1, got '${FM_PS_NATIVE:-unset}'" ;; + esac +} + +test_comm_of_self_is_bash() { + local comm + comm=$(fm_ps_field comm "$$") || fail "fm_ps_field comm returned non-zero for self" + # The test process is bash on every platform; comm must name it. + assert_contains "$comm" "bash" "comm of the test process should mention bash" + pass "fm_ps_field comm resolves the current process ($comm)" +} + +test_ppid_of_self_is_numeric() { + local ppid + ppid=$(fm_ps_field ppid "$$") + case "$ppid" in + ''|*[!0-9]*) fail "fm_ps_field ppid should be numeric, got '$ppid'" ;; + *) pass "fm_ps_field ppid is numeric ($ppid)" ;; + esac +} + +test_args_of_self_mentions_bash() { + local args + args=$(fm_ps_field args "$$") + assert_contains "$args" "bash" "args of the test process should mention bash" + pass "fm_ps_field args returns the command line" +} + +test_identity_of_self_nonempty() { + local id + id=$(fm_ps_field identity "$$") || fail "fm_ps_field identity returned non-zero for self" + [ -n "$id" ] || fail "fm_ps_field identity should be non-empty" + pass "fm_ps_field identity returns a fingerprint" +} + +test_bogus_pid_yields_empty() { + # A nonexistent pid must yield empty output on both paths (native prints + # nothing; the fallback returns non-zero). Assert on output, not exit code, + # so the check is platform-neutral. + local out + out=$(fm_ps_field ppid 999999 2>/dev/null || true) + [ -z "$out" ] || fail "fm_ps_field ppid for a bogus pid should be empty, got '$out'" + pass "fm_ps_field yields empty for a nonexistent pid" +} + +test_source_is_idempotent() { + local before=$FM_PS_NATIVE + # shellcheck source=bin/fm-ps-portable.sh + . "$ROOT/bin/fm-ps-portable.sh" + expect_code 0 "$?" "re-sourcing the shim" + [ "$FM_PS_NATIVE" = "$before" ] || fail "FM_PS_NATIVE changed on re-source ($before -> $FM_PS_NATIVE)" + pass "sourcing the shim twice is idempotent" +} + +test_native_flag_is_boolean +test_comm_of_self_is_bash +test_ppid_of_self_is_numeric +test_args_of_self_mentions_bash +test_identity_of_self_nonempty +test_bogus_pid_yields_empty +test_source_is_idempotent