Skip to content
Merged
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
22 changes: 21 additions & 1 deletion bin/fm-wake-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,30 @@ fm_pid_alive() {
}

fm_pid_identity() {
local pid=$1 out
local pid=$1 out proc_root stat_line starttime cmdline_hex
local -a stat_fields
case "$pid" in
''|*[!0-9]*) return 1 ;;
esac
proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc}
# Prefer /proc on Linux: stat field 22 (starttime, clock ticks since boot) is
# immune to the wall-clock steps that re-render the ps lstart fallback's date
# (observed as WSL2 btime drift) and would evict a live watcher; combining the
# full NUL-separated cmdline keeps PID reuse a mismatch even on a tick collision.
if [ "$(uname)" = Linux ] && [ -r "$proc_root/$pid/stat" ] && [ -r "$proc_root/$pid/cmdline" ]; then
stat_line=$(cat "$proc_root/$pid/stat" 2>/dev/null) || return 1
# After the final comm delimiter, array index 19 is proc stat field 22.
read -r -a stat_fields <<< "${stat_line##*)}"
[ "${#stat_fields[@]}" -ge 20 ] || return 1
starttime=${stat_fields[19]}
case "$starttime" in
''|*[!0-9]*) return 1 ;;
esac
cmdline_hex=$(od -An -v -tx1 "$proc_root/$pid/cmdline" 2>/dev/null | tr -d '[:space:]') || return 1
[ -n "$cmdline_hex" ] || return 1
printf 'linux-starttime=%s cmdline-hex=%s\n' "$starttime" "$cmdline_hex"
return 0
fi
# Pin LC_ALL=C so lstart's date format is locale-invariant: the identity is
# written under one locale but re-read under the machine's ambient locale, which
# would otherwise mismatch on a non-C locale (e.g. ko_KR) and reject a live watcher.
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ FM_STATE_OVERRIDE= # alternate state dir, mainly for tests
FM_DATA_OVERRIDE= # alternate data dir, mainly for tests
FM_PROJECTS_OVERRIDE= # alternate projects dir, mainly for tests
FM_CONFIG_OVERRIDE= # alternate config dir, mainly for tests
FM_PROC_ROOT_OVERRIDE= # alternate /proc root for the Linux process-identity read in fm-wake-lib.sh, mainly for tests
FM_BACKEND= # optional runtime backend override for new spawns; tmux/herdr/zellij/orca/cmux support ship/scout spawns, codex-app is not accepted
HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md)
FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state
Expand Down
2 changes: 1 addition & 1 deletion tests/fm-pr-check-security.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ write_watcher_lock() {
local state=$1 home=$2 pid=$3 identity
rm -rf "$state/.watch.lock"
mkdir "$state/.watch.lock"
identity=$(LC_ALL=C ps -p "$pid" -o lstart= -o command= 2>/dev/null | sed 's/^[[:space:]]*//')
identity=$(FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$ROOT/bin/fm-wake-lib.sh" "$pid")
[ -n "$identity" ] || fail "could not capture fake older-watcher identity"
printf '%s\n' "$pid" > "$state/.watch.lock/pid"
printf '%s\n' "$home" > "$state/.watch.lock/fm-home"
Expand Down
63 changes: 52 additions & 11 deletions tests/fm-watcher-lock.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -887,20 +887,20 @@ test_stopped_watcher_is_live_but_stale_then_exit_is_classified() {
}

test_pid_identity_is_locale_invariant() {
# The watcher records its process identity under one locale; arm/guard/turn-end
# re-read it under the machine's ambient locale. ps's lstart date format follows
# LC_TIME, so an unpinned read on a non-C locale (e.g. ko_KR) would differ only
# in the date portion and reject a genuinely live watcher. The fix pins LC_ALL=C
# inside fm_pid_identity, so its output must be byte-identical regardless of the
# caller's exported LC_ALL/LC_TIME. That invariant holds on any host because the
# pin is internal, so this stays deterministic on CI even where an alternate
# The portable fallback records its process identity under one locale, then
# arm/guard/turn-end re-read it under the machine's ambient locale. ps's lstart
# date format follows LC_TIME, so an unpinned read on a non-C locale (e.g. ko_KR)
# would reject a genuinely live watcher. The fallback pins LC_ALL=C inside
# fm_pid_identity, so its output must be byte-identical regardless of the caller's
# exported LC_ALL/LC_TIME. This stays deterministic on CI even where an alternate
# locale like ko_KR.UTF-8 is not installed (the equality then holds trivially).
local live baseline via_lc_all via_lc_time
local live no_proc baseline via_lc_all via_lc_time
sleep 300 &
live=$!
baseline=$(LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null)
via_lc_all=$(LC_ALL=ko_KR.UTF-8 bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null)
via_lc_time=$(LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null)
no_proc="$TMP_ROOT/no-proc"
baseline=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null)
via_lc_all=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=ko_KR.UTF-8 bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null)
via_lc_time=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null)
kill "$live" 2>/dev/null || true
wait "$live" 2>/dev/null || true
[ -n "$baseline" ] || fail "fm_pid_identity produced no baseline identity under LC_ALL=C"
Expand All @@ -909,8 +909,49 @@ test_pid_identity_is_locale_invariant() {
pass "fm_pid_identity is locale-invariant across LC_ALL/LC_TIME"
}

write_fake_proc_identity() {
local proc_root=$1 pid=$2 starttime=$3
mkdir -p "$proc_root/$pid"
printf '%s\n' "$pid (watcher ) with spaces) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 $starttime 20 21 22" > "$proc_root/$pid/stat"
printf 'bash\0/path with spaces/fm-watch.sh\0--flag\0' > "$proc_root/$pid/cmdline"
}

test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse() {
local dir state proc_root pid before after_time_jump after_pid_reuse
[ "$(uname)" = Linux ] || {
pass "Linux process identity clock-step regression skipped on non-Linux host"
return
}
dir=$(make_case linux-pid-identity)
state="$dir/state"
proc_root="$dir/proc"
pid=4242
mkdir -p "$proc_root"
printf 'btime 1784094040\n' > "$proc_root/stat"
write_fake_proc_identity "$proc_root" "$pid" 987654

before=$(FM_PROC_ROOT_OVERRIDE="$proc_root" FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$pid") \
|| fail "could not read initial fake Linux process identity"
printf 'btime 1784094016\n' > "$proc_root/stat"
after_time_jump=$(FM_PROC_ROOT_OVERRIDE="$proc_root" FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$pid") \
|| fail "could not re-read fake Linux process identity after btime change"

[ "$after_time_jump" = "$before" ] \
|| fail "Linux process identity changed with btime (before '$before', after '$after_time_jump')"
[ "$before" = 'linux-starttime=987654 cmdline-hex=62617368002f706174682077697468207370616365732f666d2d77617463682e7368002d2d666c616700' ] \
|| fail "Linux process identity did not combine parsed starttime field 22 with the full cmdline ('$before')"
pass "Linux process identity ignores simulated btime changes"

write_fake_proc_identity "$proc_root" "$pid" 987655
after_pid_reuse=$(FM_PROC_ROOT_OVERRIDE="$proc_root" FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$pid") \
|| fail "could not read reused fake Linux pid identity"
[ "$after_pid_reuse" != "$before" ] || fail "Linux process identity missed changed starttime for reused pid"
pass "Linux process identity detects pid reuse"
}

test_singleton_start
test_pid_identity_is_locale_invariant
test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse
test_stale_watch_lock_reclaimed
test_live_stale_watch_lock_is_actionable
test_guard_warnings
Expand Down