Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions bin/fm-harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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 ;;
Expand All @@ -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 ;;
Expand All @@ -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
Expand Down
12 changes: 7 additions & 5 deletions bin/fm-lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -20,16 +22,16 @@ 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
# Bare interpreter (e.g. node): match the harness name in its script path.
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
Expand All @@ -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
Expand Down
59 changes: 59 additions & 0 deletions bin/fm-ps-portable.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# fm-ps-portable.sh - portable process introspection for firstmate.
#
# Emulates the `ps -o <field>= -p <pid>` 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|args|ppid|identity> <pid>
# 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
}
4 changes: 3 additions & 1 deletion bin/fm-wake-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
Expand All @@ -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:]]*//'
}
Expand Down
82 changes: 82 additions & 0 deletions tests/fm-ps-portable.test.sh
Original file line number Diff line number Diff line change
@@ -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
Loading