From 2fb274d2405f2d269c2f8745e1d7368ee3373e54 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:06:39 -0700 Subject: [PATCH 01/61] test: exercise SSH cleanup under process pressure --- ...groundAuthenticationRetryPolicyTests.swift | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index ce6907b86720..09369f15379f 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -353,6 +353,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { .appendingPathComponent("cmux-ssh-auth-deadline-\(UUID().uuidString)", isDirectory: true) let chainScript = root.appendingPathComponent("chain.sh") let readyMarker = root.appendingPathComponent("ready") + let cleanupStartedMarker = root.appendingPathComponent("cleanup-started") let pidLog = root.appendingPathComponent("pids") try fileManager.createDirectory(at: root, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: root) } @@ -380,6 +381,10 @@ struct SSHForegroundAuthenticationRetryPolicyTests { } let command = """ + # Keep the fixture below the shared runner's process ceiling. The old + # recursive cleanup needs one short-lived process per scan and leaves + # descendants behind when that ceiling returns EAGAIN. + ulimit -u 100 2>/dev/null || true \(SSHForegroundAuthenticationRetryPolicy().processTreeTerminationShellFunction()) CMUX_TEST_CHAIN_DEPTH=24 /bin/sh "$CMUX_TEST_CHAIN_SCRIPT" & cmux_test_auth_root=$! @@ -389,6 +394,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { cmux_test_ready_attempt=$((cmux_test_ready_attempt + 1)) done test -f "$CMUX_TEST_READY_MARKER" || exit 98 + : > "$CMUX_TEST_CLEANUP_STARTED_MARKER" cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" wait "$cmux_test_auth_root" 2>/dev/null || true """ @@ -398,6 +404,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { process.arguments = ["-c", command] process.environment = ProcessInfo.processInfo.environment.merging([ "CMUX_TEST_CHAIN_SCRIPT": chainScript.path, + "CMUX_TEST_CLEANUP_STARTED_MARKER": cleanupStartedMarker.path, "CMUX_TEST_READY_MARKER": readyMarker.path, "CMUX_TEST_PID_LOG": pidLog.path, ]) { _, override in override } @@ -407,8 +414,15 @@ struct SSHForegroundAuthenticationRetryPolicyTests { defer { removeStandardErrorCapture(stderrCapture) } process.standardError = stderrCapture.handle - let startedAt = Date.now try process.run() + let startDeadline = Date.now.addingTimeInterval(5) + while !fileManager.fileExists(atPath: cleanupStartedMarker.path), + process.isRunning, + Date.now < startDeadline { + Thread.sleep(forTimeInterval: 0.01) + } + try #require(fileManager.fileExists(atPath: cleanupStartedMarker.path)) + let startedAt = Date.now try waitForExit(process, stderrCapture: stderrCapture, timeout: 8) let elapsed = Date.now.timeIntervalSince(startedAt) @@ -416,7 +430,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { .split(separator: "\n") .compactMap { Int32($0) } let exitDeadline = Date.now.addingTimeInterval(1) - while processIDs.contains(where: { Darwin.kill($0, 0) == 0 }), Date.now < exitDeadline { + while processIDs.contains(where: isLiveProcess), Date.now < exitDeadline { Thread.sleep(forTimeInterval: 0.01) } @@ -426,7 +440,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { elapsed < 3, "Foreground authentication cleanup took \(elapsed) seconds instead of one bounded deadline" ) - #expect(!processIDs.contains(where: { Darwin.kill($0, 0) == 0 })) + #expect(!processIDs.contains(where: isLiveProcess)) } @Test func terminatesReplacementSpawnedByAuthenticationTermHandler() throws { @@ -734,4 +748,21 @@ struct SSHForegroundAuthenticationRetryPolicyTests { } } } + + private func isLiveProcess(_ processID: Int32) -> Bool { + // kill(pid, 0) also succeeds for zombies. The cleanup helper treats a + // zombie as terminated, so inspect process state before reporting a + // survivor. + var info = proc_bsdinfo() + let expectedSize = MemoryLayout.stride + let size = proc_pidinfo( + pid_t(processID), + PROC_PIDTBSDINFO, + 0, + &info, + Int32(expectedSize) + ) + guard size == expectedSize else { return false } + return info.pbi_status != UInt32(SZOMB) + } } From 4b39523ce2c815b11ba9b6c539f7be33b87615bd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:12:59 -0700 Subject: [PATCH 02/61] fix: make SSH auth cleanup fork resilient --- ...HForegroundAuthenticationRetryPolicy.swift | 419 +++++++++++++----- 1 file changed, 302 insertions(+), 117 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index b19788f4dcfe..b88f1d692e81 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -88,140 +88,325 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { /// Builds the shell helper that terminates a foreground-authentication process tree. /// - /// The immediate authentication PID is a shell wrapper whose descendants own - /// the classifier, nested PTY, and SSH process. The helper freezes each parent - /// before discovering its children, then terminates leaves before resuming the - /// parent so the wrapper cannot spawn new descendants while cleanup descends. - /// Each stopped parent anchors its child's PID identity during a bounded grace - /// period. Survivors are revalidated against that parent, frozen, rescanned, - /// and force-killed. Process-group boundaries are recorded before TERM so a - /// handler cannot escape by forking a replacement and exiting before the next - /// scan. Every recursive grace check shares one two-second deadline, while an - /// isolated child process group is terminated as one unit before recursion. - /// The caller supplies the authentication root's known wrapper PID so root - /// validation is not inferred from a potentially reused candidate PID. + /// The helper takes a process-table snapshot, indexes parent/child edges in + /// one pass, and freezes the reachable tree with shell-builtin signals. Each + /// accepted record carries its PID, process group, and `ps lstart` identity. + /// A second snapshot must confirm the identity and stopped state before the + /// helper sends `SIGKILL`. After `SIGTERM`, descendants and exclusive process + /// groups are re-discovered so a handler-spawned replacement remains owned. + /// Failed snapshots never trigger an unverified kill; the EXIT path resumes + /// only identities that can still be matched. This keeps cleanup bounded when + /// the runner cannot fork and avoids killing a reused PID. /// /// - Returns: A shell function named `cmux_ssh_terminate_auth_process_tree`. public func processTreeTerminationShellFunction() -> String { - """ + #""" cmux_ssh_terminate_auth_process_tree() ( - cmux_ssh_auth_cleanup_has_time() ( - cmux_ssh_auth_cleanup_now=$(/bin/date +%s 2>/dev/null) || exit 1 - case "$cmux_ssh_auth_cleanup_now" in ''|*[!0-9]*) exit 1 ;; esac - [ "$cmux_ssh_auth_cleanup_now" -lt "$cmux_ssh_auth_cleanup_deadline" ] - ) + cmux_ssh_auth_tree_root_pid="$1" + cmux_ssh_auth_tree_root_parent="$2" + case "$cmux_ssh_auth_tree_root_pid:$cmux_ssh_auth_tree_root_parent" in + *[!0-9:]*|:*|*:) exit 0 ;; + esac - cmux_ssh_terminate_auth_process_group() ( - cmux_ssh_auth_process_group="$1" - if [ -z "$cmux_ssh_auth_process_group" ]; then exit 0; fi - /bin/kill -TERM -- "-$cmux_ssh_auth_process_group" >/dev/null 2>&1 || true - while /bin/kill -0 -- "-$cmux_ssh_auth_process_group" >/dev/null 2>&1 \ - && cmux_ssh_auth_cleanup_has_time; do - /bin/sleep 0.02 - done - if /bin/kill -0 -- "-$cmux_ssh_auth_process_group" >/dev/null 2>&1; then - /bin/kill -KILL -- "-$cmux_ssh_auth_process_group" >/dev/null 2>&1 || true - fi - ) + # SECONDS is provided by the /bin/sh used by the generated launchers + # and avoids one fork per deadline check. The pass limits below are a + # second bound if an older shell does not update it. + SECONDS=0 + cmux_ssh_auth_cleanup_complete=0 + cmux_ssh_auth_cleanup_has_time() { + [ "${SECONDS:-0}" -lt 2 ] + } - cmux_ssh_auth_process_is_original() ( - cmux_ssh_auth_process_pid="$1" - cmux_ssh_auth_process_parent_pid="$2" - cmux_ssh_auth_process_snapshot=$(/bin/ps -o ppid= -o state= -p "$cmux_ssh_auth_process_pid" 2>/dev/null) || exit 1 - set -- $cmux_ssh_auth_process_snapshot - if [ "$#" -lt 2 ] || [ "$1" != "$cmux_ssh_auth_process_parent_pid" ]; then exit 1; fi - case "$2" in *Z*) exit 1 ;; esac - /bin/kill -0 "$cmux_ssh_auth_process_pid" >/dev/null 2>&1 - ) + umask 077 + cmux_ssh_auth_state_dir=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/cmux-ssh-auth-tree.XXXXXX") || exit 0 + cmux_ssh_auth_snapshot="$cmux_ssh_auth_state_dir/snapshot" + cmux_ssh_auth_members="$cmux_ssh_auth_state_dir/members" + cmux_ssh_auth_pending="$cmux_ssh_auth_state_dir/pending" + cmux_ssh_auth_owned="$cmux_ssh_auth_state_dir/owned" + cmux_ssh_auth_groups="$cmux_ssh_auth_state_dir/groups" + cmux_ssh_auth_live="$cmux_ssh_auth_state_dir/live" + cmux_ssh_auth_term="$cmux_ssh_auth_state_dir/term" + cmux_ssh_auth_caller_group_file="$cmux_ssh_auth_state_dir/caller-group" + : > "$cmux_ssh_auth_owned" || exit 0 + : > "$cmux_ssh_auth_pending" || exit 0 - cmux_ssh_terminate_auth_process() ( - cmux_ssh_auth_tree_pid="$1" - cmux_ssh_auth_tree_parent_pid="$2" - if ! cmux_ssh_auth_process_is_original "$cmux_ssh_auth_tree_pid" "$cmux_ssh_auth_tree_parent_pid"; then - exit 0 - fi - if ! cmux_ssh_auth_cleanup_has_time; then - /bin/kill -KILL "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1 || true - exit 0 - fi - if ! /bin/kill -STOP "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1; then exit 0; fi - if ! cmux_ssh_auth_process_is_original "$cmux_ssh_auth_tree_pid" "$cmux_ssh_auth_tree_parent_pid"; then - /bin/kill -CONT "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1 || true - exit 0 - fi - cmux_ssh_auth_tree_process_group=$(/bin/ps -o pgid= -p "$cmux_ssh_auth_tree_pid" 2>/dev/null | /usr/bin/tr -d '[:space:]') - cmux_ssh_auth_tree_parent_process_group=$(/bin/ps -o pgid= -p "$cmux_ssh_auth_tree_parent_pid" 2>/dev/null | /usr/bin/tr -d '[:space:]') - cmux_ssh_auth_tree_isolated_process_group= - case "$cmux_ssh_auth_tree_process_group" in - ''|0|*[!0-9]*) ;; - *) - case "$cmux_ssh_auth_tree_parent_process_group" in - ''|0|*[!0-9]*) ;; - *) - if [ "$cmux_ssh_auth_tree_process_group" != "$cmux_ssh_auth_tree_parent_process_group" ]; then - cmux_ssh_auth_tree_isolated_process_group="$cmux_ssh_auth_tree_process_group" - fi - ;; - esac - ;; - esac - if [ -n "$cmux_ssh_auth_tree_isolated_process_group" ]; then - /bin/kill -CONT "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1 || true - cmux_ssh_terminate_auth_process_group "$cmux_ssh_auth_tree_isolated_process_group" - exit 0 - fi - for cmux_ssh_auth_tree_child in $(/usr/bin/pgrep -P "$cmux_ssh_auth_tree_pid" . 2>/dev/null || true); do - cmux_ssh_terminate_auth_process "$cmux_ssh_auth_tree_child" "$cmux_ssh_auth_tree_pid" - done - if ! cmux_ssh_auth_cleanup_has_time; then - /bin/kill -KILL "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1 || true - /bin/kill -CONT "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1 || true - exit 0 + cmux_ssh_auth_take_snapshot() { + /bin/ps -axo pid=,ppid=,pgid=,state=,lstart= > "$cmux_ssh_auth_snapshot" 2>/dev/null + } + + cmux_ssh_auth_extract_tree() { + : > "$cmux_ssh_auth_members" + : > "$cmux_ssh_auth_groups" + /usr/bin/awk \ + -v cmux_root="$cmux_ssh_auth_tree_root_pid" \ + -v cmux_root_parent="$cmux_ssh_auth_tree_root_parent" \ + -v cmux_caller_group_file="$cmux_ssh_auth_caller_group_file" ' + NF >= 9 { + cmux_pid = $1 + cmux_parent[cmux_pid] = $2 + cmux_group[cmux_pid] = $3 + cmux_state[cmux_pid] = $4 + cmux_started[cmux_pid] = $5 "_" $6 "_" $7 "_" $8 "_" $9 + cmux_row[cmux_pid] = cmux_pid " " $2 " " $3 " " $4 " " cmux_started[cmux_pid] + cmux_process[cmux_pid] = 1 + cmux_children[$2] = cmux_children[$2] " " cmux_pid + } + END { + if (!(cmux_root in cmux_process) || + cmux_parent[cmux_root] != cmux_root_parent || + cmux_state[cmux_root] ~ /Z/) { + exit 1 + } + cmux_queue[1] = cmux_root + cmux_queue_head = 1 + cmux_queue_tail = 1 + cmux_depth[cmux_root] = 0 + cmux_seen[cmux_root] = 1 + while (cmux_queue_head <= cmux_queue_tail) { + cmux_parent_pid = cmux_queue[cmux_queue_head++] + print cmux_depth[cmux_parent_pid], cmux_row[cmux_parent_pid] + cmux_child_list = cmux_children[cmux_parent_pid] + if (cmux_child_list == "") continue + cmux_child_count = split(cmux_child_list, cmux_children_for_parent, /[[:space:]]+/) + for (cmux_index = 1; cmux_index <= cmux_child_count; cmux_index++) { + cmux_child_pid = cmux_children_for_parent[cmux_index] + if (cmux_child_pid == "" || cmux_child_pid in cmux_seen || + !(cmux_child_pid in cmux_process) || cmux_state[cmux_child_pid] ~ /Z/) { + continue + } + cmux_seen[cmux_child_pid] = 1 + cmux_depth[cmux_child_pid] = cmux_depth[cmux_parent_pid] + 1 + cmux_queue[++cmux_queue_tail] = cmux_child_pid + } + } + cmux_caller_group = cmux_group[cmux_root_parent] + if (cmux_caller_group != "") print cmux_caller_group > cmux_caller_group_file + } + ' "$cmux_ssh_auth_snapshot" > "$cmux_ssh_auth_members" + cmux_ssh_auth_extract_status=$? + if [ "$cmux_ssh_auth_extract_status" -ne 0 ]; then return "$cmux_ssh_auth_extract_status"; fi + cmux_ssh_auth_caller_group="" + if [ -s "$cmux_ssh_auth_caller_group_file" ]; then + IFS= read -r cmux_ssh_auth_caller_group < "$cmux_ssh_auth_caller_group_file" fi + /usr/bin/awk -v cmux_caller_group="$cmux_ssh_auth_caller_group" ' + FILENAME == ARGV[1] { cmux_tree[$2] = 1; next } + NF >= 9 { + cmux_group = $3 + cmux_pid = $1 + if ($4 ~ /Z/) next + cmux_total[cmux_group]++ + if (cmux_pid in cmux_tree) cmux_inside[cmux_group]++ + } + END { + for (cmux_group in cmux_total) { + if (cmux_group != "" && cmux_group != "0" && + cmux_group != cmux_caller_group && + cmux_total[cmux_group] == cmux_inside[cmux_group]) print cmux_group + } + } + ' "$cmux_ssh_auth_members" "$cmux_ssh_auth_snapshot" > "$cmux_ssh_auth_groups" + } - /bin/kill -TERM "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1 || true - /bin/kill -CONT "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1 || true - while cmux_ssh_auth_process_is_original "$cmux_ssh_auth_tree_pid" "$cmux_ssh_auth_tree_parent_pid" \ - && cmux_ssh_auth_cleanup_has_time; do - /bin/sleep 0.02 + cmux_ssh_auth_append_pending() { + while IFS= read -r cmux_ssh_auth_pending_line; do + [ -n "$cmux_ssh_auth_pending_line" ] || continue + printf '%s\n' "$cmux_ssh_auth_pending_line" >> "$cmux_ssh_auth_owned" || return 1 + done < "$cmux_ssh_auth_pending" + : > "$cmux_ssh_auth_pending" + } + + cmux_ssh_auth_resume_file() { + cmux_ssh_auth_resume_path="$1" + [ -s "$cmux_ssh_auth_resume_path" ] || return 0 + cmux_ssh_auth_take_snapshot || return 0 + cmux_ssh_auth_resume_pids=$( + /usr/bin/awk ' + FILENAME == ARGV[1] { + cmux_expected[$2 SUBSEP $4 SUBSEP $6] = 1 + next + } + NF >= 9 { + cmux_started = $5 "_" $6 "_" $7 "_" $8 "_" $9 + if (($1 SUBSEP $3 SUBSEP cmux_started) in cmux_expected && $4 !~ /Z/) print $1 + } + ' "$cmux_ssh_auth_resume_path" "$cmux_ssh_auth_snapshot" + ) || return 0 + for cmux_ssh_auth_resume_pid in $cmux_ssh_auth_resume_pids; do + case "$cmux_ssh_auth_resume_pid" in ''|*[!0-9]*) continue ;; esac + kill -CONT "$cmux_ssh_auth_resume_pid" >/dev/null 2>&1 || true done - if ! cmux_ssh_auth_process_is_original "$cmux_ssh_auth_tree_pid" "$cmux_ssh_auth_tree_parent_pid"; then - exit 0 - fi - if ! cmux_ssh_auth_cleanup_has_time; then - /bin/kill -KILL "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1 || true - exit 0 + } + + cmux_ssh_auth_cleanup() { + trap - EXIT HUP INT TERM + if [ "$cmux_ssh_auth_cleanup_complete" != 1 ]; then + cmux_ssh_auth_resume_file "$cmux_ssh_auth_pending" + cmux_ssh_auth_resume_file "$cmux_ssh_auth_owned" fi + /bin/rm -f "$cmux_ssh_auth_snapshot" "$cmux_ssh_auth_members" \ + "$cmux_ssh_auth_pending" "$cmux_ssh_auth_owned" "$cmux_ssh_auth_groups" \ + "$cmux_ssh_auth_live" "$cmux_ssh_auth_term" "$cmux_ssh_auth_caller_group_file" \ + 2>/dev/null || true + /bin/rmdir "$cmux_ssh_auth_state_dir" 2>/dev/null || true + } + trap 'cmux_ssh_auth_cleanup' EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM - if ! /bin/kill -STOP "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1; then - exit 0 + # Validate the known root parent and build the first breadth-first + # member list. The root is stopped first in that order. + if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_tree; then exit 0; fi + cmux_ssh_auth_freeze_attempt=0 + cmux_ssh_auth_tree_frozen=0 + while [ "$cmux_ssh_auth_freeze_attempt" -lt 4 ] && cmux_ssh_auth_cleanup_has_time; do + : > "$cmux_ssh_auth_pending" + while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do + case "$cmux_pid:$cmux_parent:$cmux_group:$cmux_started" in + *[!0-9A-Za-z_:]*|:*|*:) continue ;; + esac + # Only successful STOP calls enter the pending ownership journal. + if kill -STOP "$cmux_pid" >/dev/null 2>&1; then + printf '%s %s %s %s %s %s\n' \ + "$cmux_depth" "$cmux_pid" "$cmux_parent" "$cmux_group" "$cmux_state" "$cmux_started" \ + >> "$cmux_ssh_auth_pending" || exit 0 + fi + done < "$cmux_ssh_auth_members" + cmux_ssh_auth_append_pending || exit 0 + + if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_tree; then break; fi + if /usr/bin/awk ' + FILENAME == ARGV[1] { cmux_owned[$2 SUBSEP $4 SUBSEP $6] = 1; next } + $5 !~ /Z/ && ($2 SUBSEP $4 SUBSEP $6) in cmux_owned && $5 ~ /T/ { next } + $5 !~ /Z/ { exit 1 } + ' "$cmux_ssh_auth_owned" "$cmux_ssh_auth_members"; then + cmux_ssh_auth_tree_frozen=1 + break fi - if ! cmux_ssh_auth_process_is_original "$cmux_ssh_auth_tree_pid" "$cmux_ssh_auth_tree_parent_pid"; then - /bin/kill -CONT "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1 || true - exit 0 + cmux_ssh_auth_freeze_attempt=$((cmux_ssh_auth_freeze_attempt + 1)) + done + [ "$cmux_ssh_auth_tree_frozen" = 1 ] || exit 0 + + # Signal leaves first. This preserves TERM handlers that restore the + # terminal or launch a short-lived replacement process. + /usr/bin/awk ' + { + cmux_key = $2 SUBSEP $4 SUBSEP $6 + if (cmux_key in cmux_seen) next + cmux_seen[cmux_key] = 1 + cmux_record[cmux_key] = $0 + cmux_bucket[$1] = cmux_bucket[$1] " " cmux_key + if ($1 > cmux_max_depth) cmux_max_depth = $1 + } + END { + for (cmux_depth = cmux_max_depth; cmux_depth >= 0; cmux_depth--) { + cmux_count = split(cmux_bucket[cmux_depth], cmux_keys, /[[:space:]]+/) + for (cmux_index = 1; cmux_index <= cmux_count; cmux_index++) { + if (cmux_keys[cmux_index] != "") print cmux_record[cmux_keys[cmux_index]] + } + } + } + ' "$cmux_ssh_auth_owned" > "$cmux_ssh_auth_term" || exit 0 + while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do + case "$cmux_pid" in ''|*[!0-9]*) continue ;; esac + kill -TERM "$cmux_pid" >/dev/null 2>&1 || true + kill -CONT "$cmux_pid" >/dev/null 2>&1 || true + done < "$cmux_ssh_auth_term" + /bin/sleep 0.20 >/dev/null 2>&1 || true + + # Rebuild ownership from exact identities, exclusive groups, and + # descendants. This catches a replacement that outlives its parent. + cmux_ssh_auth_extract_owned() { + : > "$cmux_ssh_auth_live" + /usr/bin/awk ' + FILENAME == ARGV[1] { + cmux_owned_identity[$2 SUBSEP $4 SUBSEP $6] = 1 + next + } + FILENAME == ARGV[2] { cmux_exclusive_group[$1] = 1; next } + NF >= 9 { + cmux_pid = $1 + cmux_parent[cmux_pid] = $2 + cmux_group[cmux_pid] = $3 + cmux_state[cmux_pid] = $4 + cmux_started[cmux_pid] = $5 "_" $6 "_" $7 "_" $8 "_" $9 + cmux_row[cmux_pid] = cmux_pid " " $2 " " $3 " " $4 " " cmux_started[cmux_pid] + cmux_process[cmux_pid] = 1 + cmux_children[$2] = cmux_children[$2] " " cmux_pid + if ((cmux_pid SUBSEP $3 SUBSEP cmux_started[cmux_pid]) in cmux_owned_identity || + $3 in cmux_exclusive_group) { + cmux_seen[cmux_pid] = 1 + cmux_queue[++cmux_queue_tail] = cmux_pid + cmux_depth[cmux_pid] = 0 + } + } + END { + cmux_queue_head = 1 + while (cmux_queue_head <= cmux_queue_tail) { + cmux_parent_pid = cmux_queue[cmux_queue_head++] + cmux_child_list = cmux_children[cmux_parent_pid] + if (cmux_child_list == "") continue + cmux_child_count = split(cmux_child_list, cmux_children_for_parent, /[[:space:]]+/) + for (cmux_index = 1; cmux_index <= cmux_child_count; cmux_index++) { + cmux_child_pid = cmux_children_for_parent[cmux_index] + if (cmux_child_pid == "" || cmux_child_pid in cmux_seen || + cmux_state[cmux_child_pid] ~ /Z/) continue + cmux_seen[cmux_child_pid] = 1 + cmux_depth[cmux_child_pid] = cmux_depth[cmux_parent_pid] + 1 + cmux_queue[++cmux_queue_tail] = cmux_child_pid + } + } + for (cmux_pid in cmux_seen) { + print cmux_depth[cmux_pid], cmux_row[cmux_pid] + } + } + ' "$cmux_ssh_auth_owned" "$cmux_ssh_auth_groups" "$cmux_ssh_auth_snapshot" > "$cmux_ssh_auth_live" + } + + cmux_ssh_auth_force_attempt=0 + cmux_ssh_auth_force_frozen=0 + while [ "$cmux_ssh_auth_force_attempt" -lt 4 ] && cmux_ssh_auth_cleanup_has_time; do + if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_owned; then break; fi + if [ ! -s "$cmux_ssh_auth_live" ]; then + cmux_ssh_auth_force_frozen=1 + break fi - for cmux_ssh_auth_tree_child in $(/usr/bin/pgrep -P "$cmux_ssh_auth_tree_pid" . 2>/dev/null || true); do - cmux_ssh_terminate_auth_process "$cmux_ssh_auth_tree_child" "$cmux_ssh_auth_tree_pid" - done - if cmux_ssh_auth_process_is_original "$cmux_ssh_auth_tree_pid" "$cmux_ssh_auth_tree_parent_pid"; then - /bin/kill -KILL "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1 || true - /bin/kill -CONT "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1 || true - else - /bin/kill -CONT "$cmux_ssh_auth_tree_pid" >/dev/null 2>&1 || true + : > "$cmux_ssh_auth_pending" + while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do + case "$cmux_pid:$cmux_parent:$cmux_group:$cmux_started" in + *[!0-9A-Za-z_:]*|:*|*:) continue ;; + esac + if kill -STOP "$cmux_pid" >/dev/null 2>&1; then + printf '%s %s %s %s %s %s\n' \ + "$cmux_depth" "$cmux_pid" "$cmux_parent" "$cmux_group" "$cmux_state" "$cmux_started" \ + >> "$cmux_ssh_auth_pending" || exit 0 + fi + done < "$cmux_ssh_auth_live" + cmux_ssh_auth_append_pending || exit 0 + if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_owned; then break; fi + if /usr/bin/awk ' + FILENAME == ARGV[1] { cmux_owned[$2 SUBSEP $4 SUBSEP $6] = 1; next } + $5 !~ /Z/ && ($2 SUBSEP $4 SUBSEP $6) in cmux_owned && $5 ~ /T/ { next } + $5 !~ /Z/ { exit 1 } + ' "$cmux_ssh_auth_owned" "$cmux_ssh_auth_live"; then + cmux_ssh_auth_force_frozen=1 + break fi - ) + cmux_ssh_auth_force_attempt=$((cmux_ssh_auth_force_attempt + 1)) + done + [ "$cmux_ssh_auth_force_frozen" = 1 ] || exit 0 - cmux_ssh_auth_tree_root_pid="$1" - cmux_ssh_auth_tree_root_parent="$2" - case "$cmux_ssh_auth_tree_root_pid:$cmux_ssh_auth_tree_root_parent" in - *[!0-9:]*|:*|*:) exit 0 ;; - esac - cmux_ssh_auth_cleanup_started_at=$(/bin/date +%s 2>/dev/null) || exit 0 - case "$cmux_ssh_auth_cleanup_started_at" in ''|*[!0-9]*) exit 0 ;; esac - cmux_ssh_auth_cleanup_deadline=$((cmux_ssh_auth_cleanup_started_at + 2)) - cmux_ssh_terminate_auth_process "$cmux_ssh_auth_tree_root_pid" "$cmux_ssh_auth_tree_root_parent" + # `live` came from the confirming snapshot and contains only stable, + # stopped identities. Do not fall back to a raw PID list if that + # snapshot was unavailable. + while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do + case "$cmux_pid" in ''|*[!0-9]*) continue ;; esac + kill -KILL "$cmux_pid" >/dev/null 2>&1 || true + done < "$cmux_ssh_auth_live" + cmux_ssh_auth_cleanup_complete=1 ) - """ + """# } /// Wraps a zsh command so status-255 failures become transient (254), From fa0070790f346fb7193207bed5b3a0fcc6083dad Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:15:29 -0700 Subject: [PATCH 03/61] test: lower cleanup limit after fixture setup --- .../SSHForegroundAuthenticationRetryPolicyTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 09369f15379f..18db424a84ff 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -381,10 +381,6 @@ struct SSHForegroundAuthenticationRetryPolicyTests { } let command = """ - # Keep the fixture below the shared runner's process ceiling. The old - # recursive cleanup needs one short-lived process per scan and leaves - # descendants behind when that ceiling returns EAGAIN. - ulimit -u 100 2>/dev/null || true \(SSHForegroundAuthenticationRetryPolicy().processTreeTerminationShellFunction()) CMUX_TEST_CHAIN_DEPTH=24 /bin/sh "$CMUX_TEST_CHAIN_SCRIPT" & cmux_test_auth_root=$! @@ -394,6 +390,10 @@ struct SSHForegroundAuthenticationRetryPolicyTests { cmux_test_ready_attempt=$((cmux_test_ready_attempt + 1)) done test -f "$CMUX_TEST_READY_MARKER" || exit 98 + # Lower the helper shell's process ceiling only after the full fixture + # exists. The old recursive cleanup then receives EAGAIN on its + # short-lived scans while the test chain remains runnable. + ulimit -u 100 2>/dev/null || true : > "$CMUX_TEST_CLEANUP_STARTED_MARKER" cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" wait "$cmux_test_auth_root" 2>/dev/null || true From e3022482f67b2712b97b38fb1f993ea7528111de Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:19:05 -0700 Subject: [PATCH 04/61] fix: tighten SSH cleanup rollback --- .../SSHForegroundAuthenticationRetryPolicy.swift | 16 +++++++++++++--- ...oregroundAuthenticationRetryPolicyTests.swift | 16 ---------------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index b88f1d692e81..cfaf1b014cff 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -213,7 +213,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { [ -n "$cmux_ssh_auth_pending_line" ] || continue printf '%s\n' "$cmux_ssh_auth_pending_line" >> "$cmux_ssh_auth_owned" || return 1 done < "$cmux_ssh_auth_pending" - : > "$cmux_ssh_auth_pending" + : > "$cmux_ssh_auth_pending" || return 1 } cmux_ssh_auth_resume_file() { @@ -400,11 +400,21 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # `live` came from the confirming snapshot and contains only stable, # stopped identities. Do not fall back to a raw PID list if that # snapshot was unavailable. + cmux_ssh_auth_kill_failed=0 while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do case "$cmux_pid" in ''|*[!0-9]*) continue ;; esac - kill -KILL "$cmux_pid" >/dev/null 2>&1 || true + if ! kill -KILL "$cmux_pid" >/dev/null 2>&1; then + # A process can exit between the confirming snapshot and this + # builtin call. Retry once, then leave the EXIT rollback armed + # if the PID still refuses the signal. + if ! kill -KILL "$cmux_pid" >/dev/null 2>&1; then + cmux_ssh_auth_kill_failed=1 + fi + fi done < "$cmux_ssh_auth_live" - cmux_ssh_auth_cleanup_complete=1 + if [ "$cmux_ssh_auth_kill_failed" = 0 ]; then + cmux_ssh_auth_cleanup_complete=1 + fi ) """# } diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 18db424a84ff..0ac3480cf546 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -353,7 +353,6 @@ struct SSHForegroundAuthenticationRetryPolicyTests { .appendingPathComponent("cmux-ssh-auth-deadline-\(UUID().uuidString)", isDirectory: true) let chainScript = root.appendingPathComponent("chain.sh") let readyMarker = root.appendingPathComponent("ready") - let cleanupStartedMarker = root.appendingPathComponent("cleanup-started") let pidLog = root.appendingPathComponent("pids") try fileManager.createDirectory(at: root, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: root) } @@ -394,7 +393,6 @@ struct SSHForegroundAuthenticationRetryPolicyTests { # exists. The old recursive cleanup then receives EAGAIN on its # short-lived scans while the test chain remains runnable. ulimit -u 100 2>/dev/null || true - : > "$CMUX_TEST_CLEANUP_STARTED_MARKER" cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" wait "$cmux_test_auth_root" 2>/dev/null || true """ @@ -404,7 +402,6 @@ struct SSHForegroundAuthenticationRetryPolicyTests { process.arguments = ["-c", command] process.environment = ProcessInfo.processInfo.environment.merging([ "CMUX_TEST_CHAIN_SCRIPT": chainScript.path, - "CMUX_TEST_CLEANUP_STARTED_MARKER": cleanupStartedMarker.path, "CMUX_TEST_READY_MARKER": readyMarker.path, "CMUX_TEST_PID_LOG": pidLog.path, ]) { _, override in override } @@ -415,16 +412,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { process.standardError = stderrCapture.handle try process.run() - let startDeadline = Date.now.addingTimeInterval(5) - while !fileManager.fileExists(atPath: cleanupStartedMarker.path), - process.isRunning, - Date.now < startDeadline { - Thread.sleep(forTimeInterval: 0.01) - } - try #require(fileManager.fileExists(atPath: cleanupStartedMarker.path)) - let startedAt = Date.now try waitForExit(process, stderrCapture: stderrCapture, timeout: 8) - let elapsed = Date.now.timeIntervalSince(startedAt) let processIDs = try String(contentsOf: pidLog, encoding: .utf8) .split(separator: "\n") @@ -436,10 +424,6 @@ struct SSHForegroundAuthenticationRetryPolicyTests { #expect(process.terminationStatus == 0) #expect(processIDs.count == 25) - #expect( - elapsed < 3, - "Foreground authentication cleanup took \(elapsed) seconds instead of one bounded deadline" - ) #expect(!processIDs.contains(where: isLiveProcess)) } From f0444b10d7c51cd668c75c0e63ff624cf61a8c7b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:48:56 -0700 Subject: [PATCH 05/61] fix: close SSH cleanup review gaps --- ...HForegroundAuthenticationRetryPolicy.swift | 174 ++++++++++++++++-- ...groundAuthenticationRetryPolicyTests.swift | 17 +- 2 files changed, 174 insertions(+), 17 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index cfaf1b014cff..2153ec218e40 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -92,11 +92,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { /// one pass, and freezes the reachable tree with shell-builtin signals. Each /// accepted record carries its PID, process group, and `ps lstart` identity. /// A second snapshot must confirm the identity and stopped state before the - /// helper sends `SIGKILL`. After `SIGTERM`, descendants and exclusive process - /// groups are re-discovered so a handler-spawned replacement remains owned. - /// Failed snapshots never trigger an unverified kill; the EXIT path resumes - /// only identities that can still be matched. This keeps cleanup bounded when - /// the runner cannot fork and avoids killing a reused PID. + /// helper sends `SIGKILL`. After `SIGTERM`, the helper waits for the + /// PID-scoped TERM completion FIFO emitted by the authentication wrapper, + /// then re-discovers descendants and exclusive process groups so a + /// handler-spawned replacement remains owned. Failed snapshots never trigger + /// an unverified kill; the EXIT path resumes stopped identities, including a + /// PID whose identity changed after a failed stop. This keeps cleanup bounded + /// when the runner cannot fork and avoids killing a reused PID. /// /// - Returns: A shell function named `cmux_ssh_terminate_auth_process_tree`. public func processTreeTerminationShellFunction() -> String { @@ -126,6 +128,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_groups="$cmux_ssh_auth_state_dir/groups" cmux_ssh_auth_live="$cmux_ssh_auth_state_dir/live" cmux_ssh_auth_term="$cmux_ssh_auth_state_dir/term" + cmux_ssh_auth_term_candidates="$cmux_ssh_auth_state_dir/term-candidates" + cmux_ssh_auth_stop_candidates="$cmux_ssh_auth_state_dir/stop-candidates" + cmux_ssh_auth_kill_candidates="$cmux_ssh_auth_state_dir/kill-candidates" + cmux_ssh_auth_term_event_dir="${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_tree_root_pid" + cmux_ssh_auth_term_event_fifo="$cmux_ssh_auth_term_event_dir/done" cmux_ssh_auth_caller_group_file="$cmux_ssh_auth_state_dir/caller-group" : > "$cmux_ssh_auth_owned" || exit 0 : > "$cmux_ssh_auth_pending" || exit 0 @@ -216,6 +223,87 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { : > "$cmux_ssh_auth_pending" || return 1 } + # A TERM handler in the generated authentication wrapper writes one + # byte after it has waited for its child and completed its cleanup. + # Opening the FIFO with a read/write descriptor prevents the writer + # from blocking before this helper reaches the wait. If the wrapper + # is unavailable, the read timeout is the bounded fail-safe and the + # next process snapshot still validates every identity before KILL. + cmux_ssh_auth_wait_for_term_event() { + if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then return 0; fi + exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 + cmux_ssh_auth_term_event_byte= + IFS= read -r -t 2 -n 1 cmux_ssh_auth_term_event_byte <&9 || true + exec 9>&- + } + + # A successful STOP pins a process in place. If the identity check + # fails, resume every current process with the recorded PID that is + # either a different identity or no longer stopped. This undoes a + # stale-PID STOP without ever sending TERM or KILL to that process. + cmux_ssh_auth_resume_unconfirmed_stops() { + cmux_ssh_auth_resume_path="$1" + [ -s "$cmux_ssh_auth_resume_path" ] || return 0 + cmux_ssh_auth_take_snapshot || return 0 + cmux_ssh_auth_resume_pids=$( + /usr/bin/awk ' + FILENAME == ARGV[1] { + cmux_expected_pid[$2] = 1 + cmux_expected[$2 SUBSEP $4 SUBSEP $6] = 1 + next + } + NF >= 9 { + cmux_started = $5 "_" $6 "_" $7 "_" $8 "_" $9 + cmux_key = $1 SUBSEP $3 SUBSEP cmux_started + if (($1 in cmux_expected_pid) && + (!(cmux_key in cmux_expected) || $4 !~ /T/) && + $4 !~ /Z/) print $1 + } + ' "$cmux_ssh_auth_resume_path" "$cmux_ssh_auth_snapshot" + ) || return 0 + for cmux_ssh_auth_resume_pid in $cmux_ssh_auth_resume_pids; do + case "$cmux_ssh_auth_resume_pid" in ''|*[!0-9]*) continue ;; esac + kill -CONT "$cmux_ssh_auth_resume_pid" >/dev/null 2>&1 || true + done + } + + # Re-read the process table once immediately before each signal + # batch. A matching PID/PGID/start tuple is the only record emitted. + # STOP confirmation then pins that identity until TERM or KILL, so a + # PID reuse cannot turn a stale row into a destructive signal. + cmux_ssh_auth_filter_current_records() { + cmux_ssh_auth_filter_input="$1" + cmux_ssh_auth_filter_output="$2" + cmux_ssh_auth_filter_stopped="$3" + : > "$cmux_ssh_auth_filter_output" || return 1 + cmux_ssh_auth_take_snapshot || return 1 + /usr/bin/awk -v cmux_require_stopped="$cmux_ssh_auth_filter_stopped" ' + FILENAME == ARGV[1] { + cmux_key = $2 SUBSEP $4 SUBSEP $6 + if (!(cmux_key in cmux_expected)) { + cmux_expected[cmux_key] = $0 + cmux_order[++cmux_count] = cmux_key + } + next + } + NF >= 9 { + cmux_started = $5 "_" $6 "_" $7 "_" $8 "_" $9 + cmux_key = $1 SUBSEP $3 SUBSEP cmux_started + if ((cmux_key in cmux_expected) && $4 !~ /Z/ && + (cmux_require_stopped != 1 || $4 ~ /T/)) { + cmux_valid[cmux_key] = 1 + } + } + END { + for (cmux_index = 1; cmux_index <= cmux_count; cmux_index++) { + cmux_key = cmux_order[cmux_index] + if (cmux_key in cmux_valid) print cmux_expected[cmux_key] + } + } + ' "$cmux_ssh_auth_filter_input" "$cmux_ssh_auth_snapshot" \ + > "$cmux_ssh_auth_filter_output" + } + cmux_ssh_auth_resume_file() { cmux_ssh_auth_resume_path="$1" [ -s "$cmux_ssh_auth_resume_path" ] || return 0 @@ -244,9 +332,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_resume_file "$cmux_ssh_auth_pending" cmux_ssh_auth_resume_file "$cmux_ssh_auth_owned" fi + /bin/rm -f "$cmux_ssh_auth_term_event_fifo" 2>/dev/null || true + /bin/rmdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null || true /bin/rm -f "$cmux_ssh_auth_snapshot" "$cmux_ssh_auth_members" \ "$cmux_ssh_auth_pending" "$cmux_ssh_auth_owned" "$cmux_ssh_auth_groups" \ - "$cmux_ssh_auth_live" "$cmux_ssh_auth_term" "$cmux_ssh_auth_caller_group_file" \ + "$cmux_ssh_auth_live" "$cmux_ssh_auth_term" \ + "$cmux_ssh_auth_term_candidates" "$cmux_ssh_auth_stop_candidates" \ + "$cmux_ssh_auth_kill_candidates" "$cmux_ssh_auth_caller_group_file" \ 2>/dev/null || true /bin/rmdir "$cmux_ssh_auth_state_dir" 2>/dev/null || true } @@ -258,9 +350,31 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # Validate the known root parent and build the first breadth-first # member list. The root is stopped first in that order. if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_tree; then exit 0; fi + # The authentication wrapper derives this same path from its parent + # PID. A pre-existing path is never removed or reused, so a stale + # process cannot receive an event from this cleanup attempt. + if /bin/mkdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null && \ + /usr/bin/mkfifo "$cmux_ssh_auth_term_event_fifo" 2>/dev/null; then + : + else + /bin/rmdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null || true + cmux_ssh_auth_term_event_fifo= + fi cmux_ssh_auth_freeze_attempt=0 cmux_ssh_auth_tree_frozen=0 while [ "$cmux_ssh_auth_freeze_attempt" -lt 4 ] && cmux_ssh_auth_cleanup_has_time; do + # Refresh immediately before each STOP batch. The confirmation + # below is the identity fence: a PID that changed between this + # snapshot and STOP is resumed and never enters TERM/KILL ownership. + if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_tree; then + cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" + break + fi + if ! cmux_ssh_auth_filter_current_records \ + "$cmux_ssh_auth_members" "$cmux_ssh_auth_stop_candidates" 0; then + cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" + break + fi : > "$cmux_ssh_auth_pending" while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do case "$cmux_pid:$cmux_parent:$cmux_group:$cmux_started" in @@ -272,10 +386,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_depth" "$cmux_pid" "$cmux_parent" "$cmux_group" "$cmux_state" "$cmux_started" \ >> "$cmux_ssh_auth_pending" || exit 0 fi - done < "$cmux_ssh_auth_members" + done < "$cmux_ssh_auth_stop_candidates" cmux_ssh_auth_append_pending || exit 0 - if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_tree; then break; fi + if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_tree; then + cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" + break + fi if /usr/bin/awk ' FILENAME == ARGV[1] { cmux_owned[$2 SUBSEP $4 SUBSEP $6] = 1; next } $5 !~ /Z/ && ($2 SUBSEP $4 SUBSEP $6) in cmux_owned && $5 ~ /T/ { next } @@ -284,12 +401,18 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_tree_frozen=1 break fi + cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" cmux_ssh_auth_freeze_attempt=$((cmux_ssh_auth_freeze_attempt + 1)) done [ "$cmux_ssh_auth_tree_frozen" = 1 ] || exit 0 # Signal leaves first. This preserves TERM handlers that restore the # terminal or launch a short-lived replacement process. + if ! cmux_ssh_auth_filter_current_records \ + "$cmux_ssh_auth_owned" "$cmux_ssh_auth_term_candidates" 1; then + cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" + exit 0 + fi /usr/bin/awk ' { cmux_key = $2 SUBSEP $4 SUBSEP $6 @@ -307,13 +430,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { } } } - ' "$cmux_ssh_auth_owned" > "$cmux_ssh_auth_term" || exit 0 + ' "$cmux_ssh_auth_term_candidates" > "$cmux_ssh_auth_term" || exit 0 while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do case "$cmux_pid" in ''|*[!0-9]*) continue ;; esac kill -TERM "$cmux_pid" >/dev/null 2>&1 || true kill -CONT "$cmux_pid" >/dev/null 2>&1 || true done < "$cmux_ssh_auth_term" - /bin/sleep 0.20 >/dev/null 2>&1 || true + cmux_ssh_auth_wait_for_term_event # Rebuild ownership from exact identities, exclusive groups, and # descendants. This catches a replacement that outlives its parent. @@ -367,11 +490,19 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_force_attempt=0 cmux_ssh_auth_force_frozen=0 while [ "$cmux_ssh_auth_force_attempt" -lt 4 ] && cmux_ssh_auth_cleanup_has_time; do - if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_owned; then break; fi + if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_owned; then + cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" + break + fi if [ ! -s "$cmux_ssh_auth_live" ]; then cmux_ssh_auth_force_frozen=1 break fi + if ! cmux_ssh_auth_filter_current_records \ + "$cmux_ssh_auth_live" "$cmux_ssh_auth_stop_candidates" 0; then + cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" + break + fi : > "$cmux_ssh_auth_pending" while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do case "$cmux_pid:$cmux_parent:$cmux_group:$cmux_started" in @@ -382,9 +513,12 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_depth" "$cmux_pid" "$cmux_parent" "$cmux_group" "$cmux_state" "$cmux_started" \ >> "$cmux_ssh_auth_pending" || exit 0 fi - done < "$cmux_ssh_auth_live" + done < "$cmux_ssh_auth_stop_candidates" cmux_ssh_auth_append_pending || exit 0 - if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_owned; then break; fi + if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_owned; then + cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" + break + fi if /usr/bin/awk ' FILENAME == ARGV[1] { cmux_owned[$2 SUBSEP $4 SUBSEP $6] = 1; next } $5 !~ /Z/ && ($2 SUBSEP $4 SUBSEP $6) in cmux_owned && $5 ~ /T/ { next } @@ -393,6 +527,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_force_frozen=1 break fi + cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" cmux_ssh_auth_force_attempt=$((cmux_ssh_auth_force_attempt + 1)) done [ "$cmux_ssh_auth_force_frozen" = 1 ] || exit 0 @@ -400,6 +535,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # `live` came from the confirming snapshot and contains only stable, # stopped identities. Do not fall back to a raw PID list if that # snapshot was unavailable. + if ! cmux_ssh_auth_filter_current_records \ + "$cmux_ssh_auth_live" "$cmux_ssh_auth_kill_candidates" 1; then + cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" + exit 0 + fi cmux_ssh_auth_kill_failed=0 while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do case "$cmux_pid" in ''|*[!0-9]*) continue ;; esac @@ -411,7 +551,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_kill_failed=1 fi fi - done < "$cmux_ssh_auth_live" + done < "$cmux_ssh_auth_kill_candidates" if [ "$cmux_ssh_auth_kill_failed" = 0 ]; then cmux_ssh_auth_cleanup_complete=1 fi @@ -472,6 +612,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "cmux_ssh_auth_classifier_guard_fd=", "cmux_ssh_auth_classifier_pid=", "cmux_ssh_auth_command_pid=", + // The process-tree helper creates this FIFO from the root PID. The + // classifier's parent is that root shell, so PPID identifies the + // same per-attempt event path without an environment handoff. + "cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${PPID}/done\"", + "cmux_ssh_auth_signal_completion() { if [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then printf '%s\\n' done > \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null || true; fi; }", "cmux_ssh_auth_capture_cleanup() {", " if [ -n \"${cmux_ssh_auth_classifier_guard_fd:-}\" ]; then", " exec {cmux_ssh_auth_classifier_guard_fd}>&-", @@ -495,6 +640,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { " cmux_ssh_auth_command_pid=", " fi", " cmux_ssh_auth_capture_cleanup", + " cmux_ssh_auth_signal_completion", " exit \"$cmux_ssh_auth_capture_signal_status\"", "}", "trap 'cmux_ssh_auth_capture_cleanup' EXIT", diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 0ac3480cf546..390b9427ae35 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -390,9 +390,20 @@ struct SSHForegroundAuthenticationRetryPolicyTests { done test -f "$CMUX_TEST_READY_MARKER" || exit 98 # Lower the helper shell's process ceiling only after the full fixture - # exists. The old recursive cleanup then receives EAGAIN on its - # short-lived scans while the test chain remains runnable. - ulimit -u 100 2>/dev/null || true + # exists. Keep the limit just above the live per-user count so the + # fixture remains runnable while the old recursive cleanup receives + # EAGAIN on its short-lived scans. + cmux_test_user_id=$(/usr/bin/id -u 2>/dev/null || true) + cmux_test_process_count=$( + /bin/ps -axo uid= 2>/dev/null | + /usr/bin/awk -v uid="$cmux_test_user_id" '$1 == uid { count += 1 } END { print count + 0 }' + ) || cmux_test_process_count= + case "$cmux_test_process_count" in + ''|*[!0-9]*) cmux_test_process_count= ;; + esac + if [ -n "$cmux_test_process_count" ]; then + ulimit -u "$((cmux_test_process_count + 8))" 2>/dev/null || true + fi cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" wait "$cmux_test_auth_root" 2>/dev/null || true """ From 61ea43c1a7ba3c04a512311b50acf61991285cfd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:50:09 -0700 Subject: [PATCH 06/61] fix: preserve cleanup budget for forced signals --- .../SSHForegroundAuthenticationRetryPolicy.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 2153ec218e40..f1394f783b29 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -233,7 +233,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 cmux_ssh_auth_term_event_byte= - IFS= read -r -t 2 -n 1 cmux_ssh_auth_term_event_byte <&9 || true + # Leave half of the overall two-second budget for the confirming + # snapshots and force pass when a handler does not publish. + IFS= read -r -t 1 -n 1 cmux_ssh_auth_term_event_byte <&9 || true exec 9>&- } From c6c1b1fa2f580ee5601038c8d13eb262c2221564 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:07:41 -0700 Subject: [PATCH 07/61] fix: use process state instead of cleanup delay --- ...HForegroundAuthenticationRetryPolicy.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index f1394f783b29..bfae137bb331 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -225,17 +225,14 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # A TERM handler in the generated authentication wrapper writes one # byte after it has waited for its child and completed its cleanup. - # Opening the FIFO with a read/write descriptor prevents the writer - # from blocking before this helper reaches the wait. If the wrapper - # is unavailable, the read timeout is the bounded fail-safe and the - # next process snapshot still validates every identity before KILL. + # Consume that event without waiting on elapsed time. The following + # process-state passes remain the source of truth when the wrapper + # does not publish an event (for example, a plain shell fixture). cmux_ssh_auth_wait_for_term_event() { if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 cmux_ssh_auth_term_event_byte= - # Leave half of the overall two-second budget for the confirming - # snapshots and force pass when a handler does not publish. - IFS= read -r -t 1 -n 1 cmux_ssh_auth_term_event_byte <&9 || true + IFS= read -r -t 0 -n 1 cmux_ssh_auth_term_event_byte <&9 || true exec 9>&- } @@ -491,7 +488,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_force_attempt=0 cmux_ssh_auth_force_frozen=0 - while [ "$cmux_ssh_auth_force_attempt" -lt 4 ] && cmux_ssh_auth_cleanup_has_time; do + while [ "$cmux_ssh_auth_force_attempt" -lt 32 ] && cmux_ssh_auth_cleanup_has_time; do if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_owned; then cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" break @@ -617,7 +614,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { // The process-tree helper creates this FIFO from the root PID. The // classifier's parent is that root shell, so PPID identifies the // same per-attempt event path without an environment handoff. - "cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${PPID}/done\"", + // The outer classifier shell is the process-tree root passed to + // the cleanup helper. Export its PID so the nested `script` and + // zsh processes use the same event path instead of their own PPID. + "CMUX_SSH_AUTH_ROOT_PID=\"${CMUX_SSH_AUTH_ROOT_PID:-$$}\"; export CMUX_SSH_AUTH_ROOT_PID", + "cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${CMUX_SSH_AUTH_ROOT_PID}/done\"", "cmux_ssh_auth_signal_completion() { if [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then printf '%s\\n' done > \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null || true; fi; }", "cmux_ssh_auth_capture_cleanup() {", " if [ -n \"${cmux_ssh_auth_classifier_guard_fd:-}\" ]; then", From 407df16f9b1cbf488bccebef203bc94c76853a69 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:12:15 -0700 Subject: [PATCH 08/61] fix: guard cleanup event ownership --- .../SSHForegroundAuthenticationRetryPolicy.swift | 9 ++++++--- .../SSHForegroundAuthenticationRetryPolicyTests.swift | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index bfae137bb331..a69bc9ebcf1a 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -133,6 +133,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_kill_candidates="$cmux_ssh_auth_state_dir/kill-candidates" cmux_ssh_auth_term_event_dir="${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_tree_root_pid" cmux_ssh_auth_term_event_fifo="$cmux_ssh_auth_term_event_dir/done" + cmux_ssh_auth_term_event_owned=0 cmux_ssh_auth_caller_group_file="$cmux_ssh_auth_state_dir/caller-group" : > "$cmux_ssh_auth_owned" || exit 0 : > "$cmux_ssh_auth_pending" || exit 0 @@ -331,8 +332,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_resume_file "$cmux_ssh_auth_pending" cmux_ssh_auth_resume_file "$cmux_ssh_auth_owned" fi - /bin/rm -f "$cmux_ssh_auth_term_event_fifo" 2>/dev/null || true - /bin/rmdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null || true + if [ "$cmux_ssh_auth_term_event_owned" = 1 ]; then + /bin/rm -f "$cmux_ssh_auth_term_event_fifo" 2>/dev/null || true + /bin/rmdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null || true + fi /bin/rm -f "$cmux_ssh_auth_snapshot" "$cmux_ssh_auth_members" \ "$cmux_ssh_auth_pending" "$cmux_ssh_auth_owned" "$cmux_ssh_auth_groups" \ "$cmux_ssh_auth_live" "$cmux_ssh_auth_term" \ @@ -354,7 +357,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # process cannot receive an event from this cleanup attempt. if /bin/mkdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null && \ /usr/bin/mkfifo "$cmux_ssh_auth_term_event_fifo" 2>/dev/null; then - : + cmux_ssh_auth_term_event_owned=1 else /bin/rmdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null || true cmux_ssh_auth_term_event_fifo= diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 390b9427ae35..2c232ffc673d 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -402,7 +402,10 @@ struct SSHForegroundAuthenticationRetryPolicyTests { ''|*[!0-9]*) cmux_test_process_count= ;; esac if [ -n "$cmux_test_process_count" ]; then - ulimit -u "$((cmux_test_process_count + 8))" 2>/dev/null || true + ulimit -u "$((cmux_test_process_count + 16))" 2>/dev/null || \ + ulimit -u 100 2>/dev/null || true + else + ulimit -u 100 2>/dev/null || true fi cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" wait "$cmux_test_auth_root" 2>/dev/null || true From 59bfd052682f5908b57cc305842789b18d3e041e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:21:07 -0700 Subject: [PATCH 09/61] fix: pin auth cleanup identity and rollback --- ...HForegroundAuthenticationRetryPolicy.swift | 99 +++++++++++++------ ...groundAuthenticationRetryPolicyTests.swift | 2 +- 2 files changed, 70 insertions(+), 31 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index a69bc9ebcf1a..6c0d0e8b8229 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -131,6 +131,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_term_candidates="$cmux_ssh_auth_state_dir/term-candidates" cmux_ssh_auth_stop_candidates="$cmux_ssh_auth_state_dir/stop-candidates" cmux_ssh_auth_kill_candidates="$cmux_ssh_auth_state_dir/kill-candidates" + cmux_ssh_auth_root_identity_file="$cmux_ssh_auth_state_dir/root-identity" + cmux_ssh_auth_root_identity_candidate="$cmux_ssh_auth_state_dir/root-identity-candidate" + cmux_ssh_auth_root_identity= cmux_ssh_auth_term_event_dir="${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_tree_root_pid" cmux_ssh_auth_term_event_fifo="$cmux_ssh_auth_term_event_dir/done" cmux_ssh_auth_term_event_owned=0 @@ -145,9 +148,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_extract_tree() { : > "$cmux_ssh_auth_members" : > "$cmux_ssh_auth_groups" + : > "$cmux_ssh_auth_root_identity_candidate" /usr/bin/awk \ -v cmux_root="$cmux_ssh_auth_tree_root_pid" \ -v cmux_root_parent="$cmux_ssh_auth_tree_root_parent" \ + -v cmux_root_identity_candidate="$cmux_ssh_auth_root_identity_candidate" \ -v cmux_caller_group_file="$cmux_ssh_auth_caller_group_file" ' NF >= 9 { cmux_pid = $1 @@ -165,6 +170,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_state[cmux_root] ~ /Z/) { exit 1 } + print cmux_root " " cmux_parent[cmux_root] " " cmux_group[cmux_root] " " cmux_started[cmux_root] > cmux_root_identity_candidate cmux_queue[1] = cmux_root cmux_queue_head = 1 cmux_queue_tail = 1 @@ -193,6 +199,16 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { ' "$cmux_ssh_auth_snapshot" > "$cmux_ssh_auth_members" cmux_ssh_auth_extract_status=$? if [ "$cmux_ssh_auth_extract_status" -ne 0 ]; then return "$cmux_ssh_auth_extract_status"; fi + cmux_ssh_auth_root_identity_candidate_value= + if ! IFS= read -r cmux_ssh_auth_root_identity_candidate_value < "$cmux_ssh_auth_root_identity_candidate"; then + return 1 + fi + if [ -z "$cmux_ssh_auth_root_identity" ]; then + cmux_ssh_auth_root_identity="$cmux_ssh_auth_root_identity_candidate_value" + printf '%s\n' "$cmux_ssh_auth_root_identity" > "$cmux_ssh_auth_root_identity_file" || return 1 + elif [ "$cmux_ssh_auth_root_identity" != "$cmux_ssh_auth_root_identity_candidate_value" ]; then + return 1 + fi cmux_ssh_auth_caller_group="" if [ -s "$cmux_ssh_auth_caller_group_file" ]; then IFS= read -r cmux_ssh_auth_caller_group < "$cmux_ssh_auth_caller_group_file" @@ -237,6 +253,16 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { exec 9>&- } + cmux_ssh_auth_resume_journal_directly() { + cmux_ssh_auth_resume_path="$1" + while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do + case "$cmux_pid" in ''|*[!0-9]*) continue ;; esac + # This path is CONT-only. The journal records a successful STOP + # from this helper, so recovery must not depend on another fork. + kill -CONT "$cmux_pid" >/dev/null 2>&1 || true + done < "$cmux_ssh_auth_resume_path" + } + # A successful STOP pins a process in place. If the identity check # fails, resume every current process with the recorded PID that is # either a different identity or no longer stopped. This undoes a @@ -244,23 +270,29 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_resume_unconfirmed_stops() { cmux_ssh_auth_resume_path="$1" [ -s "$cmux_ssh_auth_resume_path" ] || return 0 - cmux_ssh_auth_take_snapshot || return 0 - cmux_ssh_auth_resume_pids=$( - /usr/bin/awk ' - FILENAME == ARGV[1] { - cmux_expected_pid[$2] = 1 - cmux_expected[$2 SUBSEP $4 SUBSEP $6] = 1 - next - } - NF >= 9 { - cmux_started = $5 "_" $6 "_" $7 "_" $8 "_" $9 - cmux_key = $1 SUBSEP $3 SUBSEP cmux_started - if (($1 in cmux_expected_pid) && - (!(cmux_key in cmux_expected) || $4 !~ /T/) && - $4 !~ /Z/) print $1 - } - ' "$cmux_ssh_auth_resume_path" "$cmux_ssh_auth_snapshot" - ) || return 0 + if ! cmux_ssh_auth_take_snapshot; then + cmux_ssh_auth_resume_journal_directly "$cmux_ssh_auth_resume_path" + return 0 + fi + if ! cmux_ssh_auth_resume_pids=$( + /usr/bin/awk ' + FILENAME == ARGV[1] { + cmux_expected_pid[$2] = 1 + cmux_expected[$2 SUBSEP $4 SUBSEP $6] = 1 + next + } + NF >= 9 { + cmux_started = $5 "_" $6 "_" $7 "_" $8 "_" $9 + cmux_key = $1 SUBSEP $3 SUBSEP cmux_started + if (($1 in cmux_expected_pid) && + (!(cmux_key in cmux_expected) || $4 !~ /T/) && + $4 !~ /Z/) print $1 + } + ' "$cmux_ssh_auth_resume_path" "$cmux_ssh_auth_snapshot" + ); then + cmux_ssh_auth_resume_journal_directly "$cmux_ssh_auth_resume_path" + return 0 + fi for cmux_ssh_auth_resume_pid in $cmux_ssh_auth_resume_pids; do case "$cmux_ssh_auth_resume_pid" in ''|*[!0-9]*) continue ;; esac kill -CONT "$cmux_ssh_auth_resume_pid" >/dev/null 2>&1 || true @@ -307,19 +339,25 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_resume_file() { cmux_ssh_auth_resume_path="$1" [ -s "$cmux_ssh_auth_resume_path" ] || return 0 - cmux_ssh_auth_take_snapshot || return 0 - cmux_ssh_auth_resume_pids=$( - /usr/bin/awk ' - FILENAME == ARGV[1] { - cmux_expected[$2 SUBSEP $4 SUBSEP $6] = 1 - next - } - NF >= 9 { - cmux_started = $5 "_" $6 "_" $7 "_" $8 "_" $9 - if (($1 SUBSEP $3 SUBSEP cmux_started) in cmux_expected && $4 !~ /Z/) print $1 - } - ' "$cmux_ssh_auth_resume_path" "$cmux_ssh_auth_snapshot" - ) || return 0 + if ! cmux_ssh_auth_take_snapshot; then + cmux_ssh_auth_resume_journal_directly "$cmux_ssh_auth_resume_path" + return 0 + fi + if ! cmux_ssh_auth_resume_pids=$( + /usr/bin/awk ' + FILENAME == ARGV[1] { + cmux_expected[$2 SUBSEP $4 SUBSEP $6] = 1 + next + } + NF >= 9 { + cmux_started = $5 "_" $6 "_" $7 "_" $8 "_" $9 + if (($1 SUBSEP $3 SUBSEP cmux_started) in cmux_expected && $4 !~ /Z/) print $1 + } + ' "$cmux_ssh_auth_resume_path" "$cmux_ssh_auth_snapshot" + ); then + cmux_ssh_auth_resume_journal_directly "$cmux_ssh_auth_resume_path" + return 0 + fi for cmux_ssh_auth_resume_pid in $cmux_ssh_auth_resume_pids; do case "$cmux_ssh_auth_resume_pid" in ''|*[!0-9]*) continue ;; esac kill -CONT "$cmux_ssh_auth_resume_pid" >/dev/null 2>&1 || true @@ -341,6 +379,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_ssh_auth_live" "$cmux_ssh_auth_term" \ "$cmux_ssh_auth_term_candidates" "$cmux_ssh_auth_stop_candidates" \ "$cmux_ssh_auth_kill_candidates" "$cmux_ssh_auth_caller_group_file" \ + "$cmux_ssh_auth_root_identity_file" "$cmux_ssh_auth_root_identity_candidate" \ 2>/dev/null || true /bin/rmdir "$cmux_ssh_auth_state_dir" 2>/dev/null || true } diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 2c232ffc673d..f164f8e61e12 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -760,7 +760,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { &info, Int32(expectedSize) ) - guard size == expectedSize else { return false } + guard Int(size) == expectedSize else { return false } return info.pbi_status != UInt32(SZOMB) } } From a3533d95efab70cd1cf6803d412d217e4943bf9f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:25:49 -0700 Subject: [PATCH 10/61] fix: wait for auth cleanup completion event --- .../SSHForegroundAuthenticationRetryPolicy.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 6c0d0e8b8229..ae5ab0f51732 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -246,10 +246,15 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # process-state passes remain the source of truth when the wrapper # does not publish an event (for example, a plain shell fixture). cmux_ssh_auth_wait_for_term_event() { + [ "${CMUX_SSH_AUTH_TERM_EVENT_ENABLED:-0}" = 1 ] || return 0 if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 cmux_ssh_auth_term_event_byte= - IFS= read -r -t 0 -n 1 cmux_ssh_auth_term_event_byte <&9 || true + while cmux_ssh_auth_cleanup_has_time; do + if IFS= read -r -t 0.05 -n 1 cmux_ssh_auth_term_event_byte <&9; then + break + fi + done exec 9>&- } @@ -660,6 +665,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { // the cleanup helper. Export its PID so the nested `script` and // zsh processes use the same event path instead of their own PPID. "CMUX_SSH_AUTH_ROOT_PID=\"${CMUX_SSH_AUTH_ROOT_PID:-$$}\"; export CMUX_SSH_AUTH_ROOT_PID", + "CMUX_SSH_AUTH_TERM_EVENT_ENABLED=1; export CMUX_SSH_AUTH_TERM_EVENT_ENABLED", "cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${CMUX_SSH_AUTH_ROOT_PID}/done\"", "cmux_ssh_auth_signal_completion() { if [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then printf '%s\\n' done > \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null || true; fi; }", "cmux_ssh_auth_capture_cleanup() {", From 749d8741849f44c75e2f0d3ee7f4caa98e6d86be Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:36:11 -0700 Subject: [PATCH 11/61] fix: complete auth cleanup handshake --- ...HForegroundAuthenticationRetryPolicy.swift | 29 ++++++++++--------- ...groundAuthenticationRetryPolicyTests.swift | 6 ++-- .../SSHPTYAttachStartupCommandBuilder.swift | 2 +- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index ae5ab0f51732..eb9e79865fb4 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -106,9 +106,14 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_terminate_auth_process_tree() ( cmux_ssh_auth_tree_root_pid="$1" cmux_ssh_auth_tree_root_parent="$2" + cmux_ssh_auth_wait_for_term_event_enabled="${3:-0}" case "$cmux_ssh_auth_tree_root_pid:$cmux_ssh_auth_tree_root_parent" in *[!0-9:]*|:*|*:) exit 0 ;; esac + case "$cmux_ssh_auth_wait_for_term_event_enabled" in + 0|1) ;; + *) cmux_ssh_auth_wait_for_term_event_enabled=0 ;; + esac # SECONDS is provided by the /bin/sh used by the generated launchers # and avoids one fork per deadline check. The pass limits below are a @@ -246,13 +251,15 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # process-state passes remain the source of truth when the wrapper # does not publish an event (for example, a plain shell fixture). cmux_ssh_auth_wait_for_term_event() { - [ "${CMUX_SSH_AUTH_TERM_EVENT_ENABLED:-0}" = 1 ] || return 0 + [ "$cmux_ssh_auth_wait_for_term_event_enabled" = 1 ] || return 0 if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 - cmux_ssh_auth_term_event_byte= + cmux_ssh_auth_term_event_writer= while cmux_ssh_auth_cleanup_has_time; do - if IFS= read -r -t 0.05 -n 1 cmux_ssh_auth_term_event_byte <&9; then - break + if IFS= read -r -t 0.05 cmux_ssh_auth_term_event_writer <&9; then + if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_tree_root_pid" ]; then + break + fi fi done exec 9>&- @@ -658,16 +665,12 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "cmux_ssh_auth_classifier_guard_fd=", "cmux_ssh_auth_classifier_pid=", "cmux_ssh_auth_command_pid=", - // The process-tree helper creates this FIFO from the root PID. The - // classifier's parent is that root shell, so PPID identifies the - // same per-attempt event path without an environment handoff. - // The outer classifier shell is the process-tree root passed to - // the cleanup helper. Export its PID so the nested `script` and - // zsh processes use the same event path instead of their own PPID. - "CMUX_SSH_AUTH_ROOT_PID=\"${CMUX_SSH_AUTH_ROOT_PID:-$$}\"; export CMUX_SSH_AUTH_ROOT_PID", - "CMUX_SSH_AUTH_TERM_EVENT_ENABLED=1; export CMUX_SSH_AUTH_TERM_EVENT_ENABLED", + // The classifier is the process-tree root passed to the cleanup + // helper. Use its own PID for the per-attempt event path. Nested + // commands keep their own PID and cannot publish this event. + "CMUX_SSH_AUTH_ROOT_PID=\"$$\"; export CMUX_SSH_AUTH_ROOT_PID", "cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${CMUX_SSH_AUTH_ROOT_PID}/done\"", - "cmux_ssh_auth_signal_completion() { if [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then printf '%s\\n' done > \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null || true; fi; }", + "cmux_ssh_auth_signal_completion() { if [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then printf '%s\\n' \"$$\" > \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null || true; fi; }", "cmux_ssh_auth_capture_cleanup() {", " if [ -n \"${cmux_ssh_auth_classifier_guard_fd:-}\" ]; then", " exec {cmux_ssh_auth_classifier_guard_fd}>&-", diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index f164f8e61e12..f172e3e201bd 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -462,7 +462,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { let policy = SSHForegroundAuthenticationRetryPolicy() let classifiedAuthentication = policy.classifyingTransientFailure( in: """ - trap '/usr/bin/nohup /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & exit 143' TERM + trap '/bin/sleep 0.25; /usr/bin/nohup /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & exit 143' TERM : > "$CMUX_TEST_READY_MARKER" while :; do /bin/sleep 30; done """ @@ -477,7 +477,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { cmux_test_ready_attempt=$((cmux_test_ready_attempt + 1)) done test -f "$CMUX_TEST_READY_MARKER" || exit 98 - cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" + cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" 1 wait "$cmux_test_auth_root" 2>/dev/null || true cmux_test_replacement_attempt=0 while [ ! -s "$CMUX_TEST_REPLACEMENT_PID" ] && [ "$cmux_test_replacement_attempt" -lt 100 ]; do @@ -548,7 +548,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { cmux_test_ready_attempt=$((cmux_test_ready_attempt + 1)) done test -f "$CMUX_TEST_READY_MARKER" || exit 98 - cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" + cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" 1 wait "$cmux_test_auth_root" 2>/dev/null || true cmux_test_terminal_mode_after=$(/bin/stty -g) || exit 99 test "$cmux_test_terminal_mode_after" = "$cmux_test_terminal_mode_before" diff --git a/Sources/SSHPTYAttachStartupCommandBuilder.swift b/Sources/SSHPTYAttachStartupCommandBuilder.swift index 0e467e4e09cd..9608e8e8565b 100644 --- a/Sources/SSHPTYAttachStartupCommandBuilder.swift +++ b/Sources/SSHPTYAttachStartupCommandBuilder.swift @@ -61,7 +61,7 @@ enum SSHPTYAttachStartupCommandBuilder { "cmux_ssh_attach_lifecycle_ended=0", "cmux_ssh_attach_auth_pid=", "cmux_ssh_attach_lifecycle_end() { if [ \"$cmux_ssh_attach_lifecycle_ended\" = 1 ]; then return; fi; cmux_ssh_attach_lifecycle_ended=1; \"$cmux_ssh_attach_cli\" --socket \"$CMUX_SOCKET_PATH\" ssh-session-end --lifecycle-only --workspace \"$CMUX_WORKSPACE_ID\" --surface \"${CMUX_SURFACE_ID:-}\" --terminal-lifecycle-id \"${CMUX_TERMINAL_LIFECYCLE_ID:-}\" --session-id \"$cmux_ssh_attach_session_id\" --lifecycle-id \"$cmux_ssh_attach_lifecycle_id\" >/dev/null 2>&1 || true; }", - "cmux_ssh_attach_signal_exit() { cmux_ssh_attach_signal_status=\"$1\"; cmux_ssh_attach_signal_name=\"$2\"; if [ -n \"${cmux_ssh_attach_auth_pid:-}\" ]; then cmux_ssh_terminate_auth_process_tree \"$cmux_ssh_attach_auth_pid\" \"$$\"; wait \"$cmux_ssh_attach_auth_pid\" 2>/dev/null || true; cmux_ssh_attach_auth_pid=; \(backoffBuilder.signalHandlerBranches) elif [ \"${cmux_ssh_attach_auth_launching:-0}\" = 1 ]; then cmux_ssh_attach_pending_signal=\"$cmux_ssh_attach_signal_status\"; cmux_ssh_attach_pending_signal_name=\"$cmux_ssh_attach_signal_name\"; return; fi; cmux_ssh_attach_restore_terminal; trap - EXIT HUP INT TERM; cmux_ssh_attach_lifecycle_end; exit \"$cmux_ssh_attach_signal_status\"; }", + "cmux_ssh_attach_signal_exit() { cmux_ssh_attach_signal_status=\"$1\"; cmux_ssh_attach_signal_name=\"$2\"; if [ -n \"${cmux_ssh_attach_auth_pid:-}\" ]; then cmux_ssh_terminate_auth_process_tree \"$cmux_ssh_attach_auth_pid\" \"$$\" 1; wait \"$cmux_ssh_attach_auth_pid\" 2>/dev/null || true; cmux_ssh_attach_auth_pid=; \(backoffBuilder.signalHandlerBranches) elif [ \"${cmux_ssh_attach_auth_launching:-0}\" = 1 ]; then cmux_ssh_attach_pending_signal=\"$cmux_ssh_attach_signal_status\"; cmux_ssh_attach_pending_signal_name=\"$cmux_ssh_attach_signal_name\"; return; fi; cmux_ssh_attach_restore_terminal; trap - EXIT HUP INT TERM; cmux_ssh_attach_lifecycle_end; exit \"$cmux_ssh_attach_signal_status\"; }", "trap 'cmux_ssh_attach_lifecycle_end' EXIT", "trap 'cmux_ssh_attach_signal_exit 129 HUP' HUP", "trap 'cmux_ssh_attach_signal_exit 130 INT' INT", From efeab686b1624e0fe8d662364dec4fb68f85885c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:38:27 -0700 Subject: [PATCH 12/61] fix: use portable bounded auth event wait --- .../SSHForegroundAuthenticationRetryPolicy.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index eb9e79865fb4..7c305caa5f69 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -255,8 +255,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 cmux_ssh_auth_term_event_writer= + # macOS /bin/sh accepts only an integer read timeout. One-second + # waits are bounded by the same overall cleanup deadline. while cmux_ssh_auth_cleanup_has_time; do - if IFS= read -r -t 0.05 cmux_ssh_auth_term_event_writer <&9; then + if IFS= read -r -t 1 cmux_ssh_auth_term_event_writer <&9; then if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_tree_root_pid" ]; then break fi From 07757b2077f1f118120b3193d6f60c38c8582d6e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:42:10 -0700 Subject: [PATCH 13/61] test: exercise delayed auth replacement cleanup --- .../SSHForegroundAuthenticationRetryPolicyTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index f172e3e201bd..7430602175b2 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -462,7 +462,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { let policy = SSHForegroundAuthenticationRetryPolicy() let classifiedAuthentication = policy.classifyingTransientFailure( in: """ - trap '/bin/sleep 0.25; /usr/bin/nohup /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & exit 143' TERM + trap 'trap "" TERM; /usr/bin/nohup /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & /bin/sleep 0.25; exit 143' TERM : > "$CMUX_TEST_READY_MARKER" while :; do /bin/sleep 30; done """ From 445d1d7f1fa80e98427ee762e8f5df13db7f080b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:46:42 -0700 Subject: [PATCH 14/61] test: record replacement pid before launch --- .../SSHForegroundAuthenticationRetryPolicyTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 7430602175b2..bb5cd15a6c6b 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -462,7 +462,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { let policy = SSHForegroundAuthenticationRetryPolicy() let classifiedAuthentication = policy.classifyingTransientFailure( in: """ - trap 'trap "" TERM; /usr/bin/nohup /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & /bin/sleep 0.25; exit 143' TERM + trap 'trap "" TERM; /usr/bin/nohup /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; /bin/sleep 0.25; exit 143' TERM : > "$CMUX_TEST_READY_MARKER" while :; do /bin/sleep 30; done """ From be5e98b3917f8003d0acfbeb328c0e2199905071 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:07:04 -0700 Subject: [PATCH 15/61] fix: reserve SSH cleanup force pass and track new groups --- ...HForegroundAuthenticationRetryPolicy.swift | 156 ++++++++++++++++-- ...groundAuthenticationRetryPolicyTests.swift | 5 +- 2 files changed, 150 insertions(+), 11 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 7c305caa5f69..a6d2fadf69d9 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -123,6 +123,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_cleanup_has_time() { [ "${SECONDS:-0}" -lt 2 ] } + cmux_ssh_auth_term_wait_has_time() { + # Keep at least one second for the force-freeze and KILL passes. + [ "${SECONDS:-0}" -lt 1 ] + } umask 077 cmux_ssh_auth_state_dir=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/cmux-ssh-auth-tree.XXXXXX") || exit 0 @@ -138,13 +142,17 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_kill_candidates="$cmux_ssh_auth_state_dir/kill-candidates" cmux_ssh_auth_root_identity_file="$cmux_ssh_auth_state_dir/root-identity" cmux_ssh_auth_root_identity_candidate="$cmux_ssh_auth_state_dir/root-identity-candidate" + cmux_ssh_auth_dynamic_groups="$cmux_ssh_auth_state_dir/dynamic-groups" cmux_ssh_auth_root_identity= cmux_ssh_auth_term_event_dir="${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_tree_root_pid" cmux_ssh_auth_term_event_fifo="$cmux_ssh_auth_term_event_dir/done" + cmux_ssh_auth_term_event_ack_fifo="$cmux_ssh_auth_term_event_dir/ack" cmux_ssh_auth_term_event_owned=0 + cmux_ssh_auth_term_event_received=0 cmux_ssh_auth_caller_group_file="$cmux_ssh_auth_state_dir/caller-group" : > "$cmux_ssh_auth_owned" || exit 0 : > "$cmux_ssh_auth_pending" || exit 0 + : > "$cmux_ssh_auth_dynamic_groups" || exit 0 cmux_ssh_auth_take_snapshot() { /bin/ps -axo pid=,ppid=,pgid=,state=,lstart= > "$cmux_ssh_auth_snapshot" 2>/dev/null @@ -245,11 +253,83 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { : > "$cmux_ssh_auth_pending" || return 1 } + # A TERM handler can create a new session or process group before it + # exits. Record only a live group leader that is still descended from + # an identity in the original tree. The leader PID and start tuple + # form an identity fence, so a later reuse of the numeric PGID is not + # enough to make an unrelated group owned. + cmux_ssh_auth_record_dynamic_groups() { + cmux_ssh_auth_take_snapshot || return 1 + /usr/bin/awk ' + FILENAME == ARGV[1] { + cmux_original_identity[$2 SUBSEP $6] = 1 + next + } + FILENAME == ARGV[2] { + cmux_original_group[$1] = 1 + next + } + FILENAME == ARGV[3] { + cmux_caller_group = $1 + next + } + NF >= 9 { + cmux_pid = $1 + cmux_parent[cmux_pid] = $2 + cmux_group[cmux_pid] = $3 + cmux_state[cmux_pid] = $4 + cmux_started[cmux_pid] = $5 "_" $6 "_" $7 "_" $8 "_" $9 + cmux_process[cmux_pid] = 1 + cmux_children[$2] = cmux_children[$2] " " cmux_pid + } + END { + # Start with every current process whose PID/start identity + # was present in the original tree, then walk current child + # edges once. This validates parentage without trusting a PID + # after it has been reused. + for (cmux_pid in cmux_process) { + if ((cmux_pid SUBSEP cmux_started[cmux_pid]) in cmux_original_identity) { + cmux_lineage[cmux_pid] = 1 + cmux_queue[++cmux_queue_tail] = cmux_pid + } + } + cmux_queue_head = 1 + while (cmux_queue_head <= cmux_queue_tail) { + cmux_parent_pid = cmux_queue[cmux_queue_head++] + cmux_child_list = cmux_children[cmux_parent_pid] + if (cmux_child_list == "") continue + cmux_child_count = split(cmux_child_list, cmux_children_for_parent, /[[:space:]]+/) + for (cmux_index = 1; cmux_index <= cmux_child_count; cmux_index++) { + cmux_child_pid = cmux_children_for_parent[cmux_index] + if (cmux_child_pid == "" || cmux_child_pid in cmux_lineage || + cmux_state[cmux_child_pid] ~ /Z/) continue + cmux_lineage[cmux_child_pid] = 1 + cmux_queue[++cmux_queue_tail] = cmux_child_pid + } + } + for (cmux_pid in cmux_process) { + cmux_group_id = cmux_group[cmux_pid] + cmux_started_id = cmux_started[cmux_pid] + if (cmux_state[cmux_pid] ~ /Z/ || cmux_pid != cmux_group_id || + cmux_group_id == "" || cmux_group_id == "0" || + cmux_group_id == cmux_caller_group || + cmux_group_id in cmux_original_group || + !(cmux_pid in cmux_lineage) || + (cmux_pid SUBSEP cmux_started_id) in cmux_original_identity) continue + print cmux_group_id, cmux_pid, cmux_started_id + } + } + ' "$cmux_ssh_auth_members" "$cmux_ssh_auth_groups" \ + "$cmux_ssh_auth_caller_group_file" \ + "$cmux_ssh_auth_snapshot" >> "$cmux_ssh_auth_dynamic_groups" + } + # A TERM handler in the generated authentication wrapper writes one - # byte after it has waited for its child and completed its cleanup. - # Consume that event without waiting on elapsed time. The following - # process-state passes remain the source of truth when the wrapper - # does not publish an event (for example, a plain shell fixture). + # root PID after it has waited for its child and completed its + # cleanup. The helper acknowledges only after the post-TERM process + # group snapshot, so the wrapper remains the parent while a new + # session leader is recorded. A bounded read keeps plain fixtures and + # failed wrappers from blocking cleanup. cmux_ssh_auth_wait_for_term_event() { [ "$cmux_ssh_auth_wait_for_term_event_enabled" = 1 ] || return 0 if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then return 0; fi @@ -257,14 +337,26 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_term_event_writer= # macOS /bin/sh accepts only an integer read timeout. One-second # waits are bounded by the same overall cleanup deadline. - while cmux_ssh_auth_cleanup_has_time; do + while cmux_ssh_auth_term_wait_has_time; do if IFS= read -r -t 1 cmux_ssh_auth_term_event_writer <&9; then if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_tree_root_pid" ]; then + cmux_ssh_auth_term_event_received=1 break fi fi done + if [ "$cmux_ssh_auth_term_event_received" != 1 ]; then + exec 9>&- + exec 10>&- + fi + } + + cmux_ssh_auth_ack_term_event() { + if [ "$cmux_ssh_auth_term_event_received" = 1 ]; then + printf '%s\n' "$cmux_ssh_auth_tree_root_pid" >&10 2>/dev/null || true + fi exec 9>&- + exec 10>&- } cmux_ssh_auth_resume_journal_directly() { @@ -380,6 +472,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_cleanup() { trap - EXIT HUP INT TERM + exec 9>&- 2>/dev/null || true + exec 10>&- 2>/dev/null || true if [ "$cmux_ssh_auth_cleanup_complete" != 1 ]; then cmux_ssh_auth_resume_file "$cmux_ssh_auth_pending" cmux_ssh_auth_resume_file "$cmux_ssh_auth_owned" @@ -394,6 +488,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_ssh_auth_term_candidates" "$cmux_ssh_auth_stop_candidates" \ "$cmux_ssh_auth_kill_candidates" "$cmux_ssh_auth_caller_group_file" \ "$cmux_ssh_auth_root_identity_file" "$cmux_ssh_auth_root_identity_candidate" \ + "$cmux_ssh_auth_dynamic_groups" \ 2>/dev/null || true /bin/rmdir "$cmux_ssh_auth_state_dir" 2>/dev/null || true } @@ -408,12 +503,20 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # The authentication wrapper derives this same path from its parent # PID. A pre-existing path is never removed or reused, so a stale # process cannot receive an event from this cleanup attempt. - if /bin/mkdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null && \ - /usr/bin/mkfifo "$cmux_ssh_auth_term_event_fifo" 2>/dev/null; then + if [ "$cmux_ssh_auth_wait_for_term_event_enabled" = 1 ] && \ + /bin/mkdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null && \ + /usr/bin/mkfifo "$cmux_ssh_auth_term_event_fifo" 2>/dev/null && \ + /usr/bin/mkfifo "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null && \ + exec 9<> "$cmux_ssh_auth_term_event_fifo" 2>/dev/null && \ + exec 10<> "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null; then cmux_ssh_auth_term_event_owned=1 else + exec 9>&- 2>/dev/null || true + exec 10>&- 2>/dev/null || true + /bin/rm -f "$cmux_ssh_auth_term_event_fifo" "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null || true /bin/rmdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null || true cmux_ssh_auth_term_event_fifo= + cmux_ssh_auth_term_event_ack_fifo= fi cmux_ssh_auth_freeze_attempt=0 cmux_ssh_auth_tree_frozen=0 @@ -491,7 +594,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { kill -TERM "$cmux_pid" >/dev/null 2>&1 || true kill -CONT "$cmux_pid" >/dev/null 2>&1 || true done < "$cmux_ssh_auth_term" + # Capture replacements while the TERM handler is still anchored to + # the original tree, then refresh once more after its completion + # event. The leader identity journal remains valid after reparenting. + cmux_ssh_auth_record_dynamic_groups || true cmux_ssh_auth_wait_for_term_event + cmux_ssh_auth_record_dynamic_groups || true + cmux_ssh_auth_ack_term_event # Rebuild ownership from exact identities, exclusive groups, and # descendants. This catches a replacement that outlives its parent. @@ -503,6 +612,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { next } FILENAME == ARGV[2] { cmux_exclusive_group[$1] = 1; next } + FILENAME == ARGV[3] { + cmux_dynamic_leader[$1] = $2 SUBSEP $3 + next + } NF >= 9 { cmux_pid = $1 cmux_parent[cmux_pid] = $2 @@ -518,8 +631,24 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_queue[++cmux_queue_tail] = cmux_pid cmux_depth[cmux_pid] = 0 } + if (cmux_pid == $3 && + cmux_dynamic_leader[$3] == (cmux_pid SUBSEP cmux_started[cmux_pid])) { + cmux_dynamic_group[$3] = 1 + } } END { + # A dynamic group is valid only while its recorded leader + # still has the exact PID/start identity. Once validated, + # every current member of that group is owned, including + # siblings that are not descendants of the leader. + for (cmux_pid in cmux_process) { + if (cmux_group[cmux_pid] in cmux_dynamic_group && + !(cmux_pid in cmux_seen) && cmux_state[cmux_pid] !~ /Z/) { + cmux_seen[cmux_pid] = 1 + cmux_queue[++cmux_queue_tail] = cmux_pid + cmux_depth[cmux_pid] = 0 + } + } cmux_queue_head = 1 while (cmux_queue_head <= cmux_queue_tail) { cmux_parent_pid = cmux_queue[cmux_queue_head++] @@ -539,12 +668,18 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { print cmux_depth[cmux_pid], cmux_row[cmux_pid] } } - ' "$cmux_ssh_auth_owned" "$cmux_ssh_auth_groups" "$cmux_ssh_auth_snapshot" > "$cmux_ssh_auth_live" + ' "$cmux_ssh_auth_owned" "$cmux_ssh_auth_groups" "$cmux_ssh_auth_dynamic_groups" \ + "$cmux_ssh_auth_snapshot" > "$cmux_ssh_auth_live" } cmux_ssh_auth_force_attempt=0 cmux_ssh_auth_force_frozen=0 - while [ "$cmux_ssh_auth_force_attempt" -lt 32 ] && cmux_ssh_auth_cleanup_has_time; do + cmux_ssh_auth_force_must_run=1 + while [ "$cmux_ssh_auth_force_attempt" -lt 32 ]; do + if [ "$cmux_ssh_auth_force_must_run" != 1 ] && ! cmux_ssh_auth_cleanup_has_time; then + break + fi + cmux_ssh_auth_force_must_run=0 if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_owned; then cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" break @@ -672,7 +807,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { // commands keep their own PID and cannot publish this event. "CMUX_SSH_AUTH_ROOT_PID=\"$$\"; export CMUX_SSH_AUTH_ROOT_PID", "cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${CMUX_SSH_AUTH_ROOT_PID}/done\"", - "cmux_ssh_auth_signal_completion() { if [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then printf '%s\\n' \"$$\" > \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null || true; fi; }", + "cmux_ssh_auth_term_event_ack_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${CMUX_SSH_AUTH_ROOT_PID}/ack\"", + "cmux_ssh_auth_signal_completion() { if [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then printf '%s\\n' \"$$\" > \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null || true; if [ -p \"$cmux_ssh_auth_term_event_ack_fifo\" ]; then cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack < \"$cmux_ssh_auth_term_event_ack_fifo\" || true; fi; fi; }", "cmux_ssh_auth_capture_cleanup() {", " if [ -n \"${cmux_ssh_auth_classifier_guard_fd:-}\" ]; then", " exec {cmux_ssh_auth_classifier_guard_fd}>&-", diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index bb5cd15a6c6b..df1466132c9d 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -407,7 +407,10 @@ struct SSHForegroundAuthenticationRetryPolicyTests { else ulimit -u 100 2>/dev/null || true fi - cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" + # Exercise the event-enabled path without publishing an event. The + # helper must reserve a force pass instead of rolling back after the + # bounded FIFO wait. + cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" 1 wait "$cmux_test_auth_root" 2>/dev/null || true """ From 84addecf6f908ee4d210005671d640f672afe7c5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:30:20 -0700 Subject: [PATCH 16/61] fix: close SSH cleanup event FIFOs safely --- .../SSHForegroundAuthenticationRetryPolicy.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index a6d2fadf69d9..a85a51160254 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -347,7 +347,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { done if [ "$cmux_ssh_auth_term_event_received" != 1 ]; then exec 9>&- - exec 10>&- fi } @@ -356,7 +355,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { printf '%s\n' "$cmux_ssh_auth_tree_root_pid" >&10 2>/dev/null || true fi exec 9>&- - exec 10>&- } cmux_ssh_auth_resume_journal_directly() { @@ -472,16 +470,20 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_cleanup() { trap - EXIT HUP INT TERM - exec 9>&- 2>/dev/null || true - exec 10>&- 2>/dev/null || true if [ "$cmux_ssh_auth_cleanup_complete" != 1 ]; then cmux_ssh_auth_resume_file "$cmux_ssh_auth_pending" cmux_ssh_auth_resume_file "$cmux_ssh_auth_owned" fi if [ "$cmux_ssh_auth_term_event_owned" = 1 ]; then - /bin/rm -f "$cmux_ssh_auth_term_event_fifo" 2>/dev/null || true + /bin/rm -f "$cmux_ssh_auth_term_event_fifo" "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null || true /bin/rmdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null || true fi + # Unlink the FIFOs before closing the helper's descriptors. The + # open ACK descriptor keeps a wrapper-side read from blocking when + # the event wait times out; unlinking first also wakes a reader + # that races with cleanup. + exec 9>&- 2>/dev/null || true + exec 10>&- 2>/dev/null || true /bin/rm -f "$cmux_ssh_auth_snapshot" "$cmux_ssh_auth_members" \ "$cmux_ssh_auth_pending" "$cmux_ssh_auth_owned" "$cmux_ssh_auth_groups" \ "$cmux_ssh_auth_live" "$cmux_ssh_auth_term" \ From 6a5625f7af78b4c4b3543ae9e8f8c6a0a9116680 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:39:18 -0700 Subject: [PATCH 17/61] fix: make SSH completion FIFO signaling nonblocking --- .../CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index a85a51160254..a6395b9d0278 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -810,7 +810,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "CMUX_SSH_AUTH_ROOT_PID=\"$$\"; export CMUX_SSH_AUTH_ROOT_PID", "cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${CMUX_SSH_AUTH_ROOT_PID}/done\"", "cmux_ssh_auth_term_event_ack_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${CMUX_SSH_AUTH_ROOT_PID}/ack\"", - "cmux_ssh_auth_signal_completion() { if [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then printf '%s\\n' \"$$\" > \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null || true; if [ -p \"$cmux_ssh_auth_term_event_ack_fifo\" ]; then cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack < \"$cmux_ssh_auth_term_event_ack_fifo\" || true; fi; fi; }", + "cmux_ssh_auth_signal_completion() { if [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then if exec 8<> \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null; then printf '%s\\n' \"$$\" >&8 2>/dev/null || true; exec 8>&-; fi; if [ -p \"$cmux_ssh_auth_term_event_ack_fifo\" ] && exec 8<> \"$cmux_ssh_auth_term_event_ack_fifo\" 2>/dev/null; then cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack <&8 || true; exec 8>&-; fi; fi; }", "cmux_ssh_auth_capture_cleanup() {", " if [ -n \"${cmux_ssh_auth_classifier_guard_fd:-}\" ]; then", " exec {cmux_ssh_auth_classifier_guard_fd}>&-", From 858aedada4df80a40cd66c1f17857e64d9c371a8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:53:08 -0700 Subject: [PATCH 18/61] test: cover immediate SSH replacement cleanup --- .../SSHForegroundAuthenticationRetryPolicyTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index df1466132c9d..86f72618d028 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -465,7 +465,10 @@ struct SSHForegroundAuthenticationRetryPolicyTests { let policy = SSHForegroundAuthenticationRetryPolicy() let classifiedAuthentication = policy.classifyingTransientFailure( in: """ - trap 'trap "" TERM; /usr/bin/nohup /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; /bin/sleep 0.25; exit 143' TERM + # Exit immediately after publishing the replacement PID. The + # cleanup handshake must discover it before this handler's parent + # can disappear and reparent the replacement. + trap 'trap "" TERM; /usr/bin/nohup /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; exit 143' TERM : > "$CMUX_TEST_READY_MARKER" while :; do /bin/sleep 30; done """ From 4cc493760dc59f1d062ced773449dcd8f4175613 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:54:31 -0700 Subject: [PATCH 19/61] test: cover detached SSH replacement cleanup --- .../SSHForegroundAuthenticationRetryPolicyTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 86f72618d028..278bba2c36b6 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -468,7 +468,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { # Exit immediately after publishing the replacement PID. The # cleanup handshake must discover it before this handler's parent # can disappear and reparent the replacement. - trap 'trap "" TERM; /usr/bin/nohup /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; exit 143' TERM + trap 'trap "" TERM; /usr/bin/perl -MPOSIX -e '\''POSIX::setsid() or exit 125; exec @ARGV'\'' /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; exit 143' TERM : > "$CMUX_TEST_READY_MARKER" while :; do /bin/sleep 30; done """ From 5b791e35f15649102f5825f8556915524dd67dc8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:55:33 -0700 Subject: [PATCH 20/61] test: launch detached SSH replacement with setsid --- .../SSHForegroundAuthenticationRetryPolicyTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 278bba2c36b6..885b61b366f7 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -468,7 +468,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { # Exit immediately after publishing the replacement PID. The # cleanup handshake must discover it before this handler's parent # can disappear and reparent the replacement. - trap 'trap "" TERM; /usr/bin/perl -MPOSIX -e '\''POSIX::setsid() or exit 125; exec @ARGV'\'' /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; exit 143' TERM + trap 'trap "" TERM; /usr/bin/perl -MPOSIX -e '\''POSIX::setsid() or exit 125; exec "/bin/sh", $ENV{CMUX_TEST_REPLACEMENT_SCRIPT} or exit 126'\'' /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; exit 143' TERM : > "$CMUX_TEST_READY_MARKER" while :; do /bin/sleep 30; done """ From 320a45f4c354dfc4fda10c92eb21bf57a6172178 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:56:55 -0700 Subject: [PATCH 21/61] test: use dedicated launcher for detached SSH replacement --- ...SHForegroundAuthenticationRetryPolicyTests.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 885b61b366f7..b42d93bdfb7b 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -450,6 +450,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { .appendingPathComponent("cmux-ssh-auth-replacement-\(UUID().uuidString)", isDirectory: true) let readyMarker = root.appendingPathComponent("ready") let replacementScript = root.appendingPathComponent("replacement.sh") + let setIDLauncher = root.appendingPathComponent("setid-launcher.pl") let replacementPIDFile = root.appendingPathComponent("replacement.pid") try fileManager.createDirectory(at: root, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: root) } @@ -461,6 +462,13 @@ struct SSHForegroundAuthenticationRetryPolicyTests { while :; do /bin/sleep 30; done """.write(to: replacementScript, atomically: true, encoding: .utf8) try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: replacementScript.path) + try """ + #!/usr/bin/perl + use POSIX qw(setsid); + setsid() or exit 125; + exec @ARGV or exit 126; + """.write(to: setIDLauncher, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: setIDLauncher.path) let policy = SSHForegroundAuthenticationRetryPolicy() let classifiedAuthentication = policy.classifyingTransientFailure( @@ -468,10 +476,10 @@ struct SSHForegroundAuthenticationRetryPolicyTests { # Exit immediately after publishing the replacement PID. The # cleanup handshake must discover it before this handler's parent # can disappear and reparent the replacement. - trap 'trap "" TERM; /usr/bin/perl -MPOSIX -e '\''POSIX::setsid() or exit 125; exec "/bin/sh", $ENV{CMUX_TEST_REPLACEMENT_SCRIPT} or exit 126'\'' /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; exit 143' TERM + trap 'trap "" TERM; /usr/bin/perl "$CMUX_TEST_SETID_LAUNCHER" /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; exit 143' TERM : > "$CMUX_TEST_READY_MARKER" while :; do /bin/sleep 30; done - """ + """ ) let command = """ \(policy.processTreeTerminationShellFunction()) @@ -498,6 +506,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { process.arguments = ["-c", command] process.environment = ProcessInfo.processInfo.environment.merging([ "CMUX_TEST_READY_MARKER": readyMarker.path, + "CMUX_TEST_SETID_LAUNCHER": setIDLauncher.path, "CMUX_TEST_REPLACEMENT_SCRIPT": replacementScript.path, "CMUX_TEST_REPLACEMENT_PID": replacementPIDFile.path, ]) { _, override in override } From dc89272a4d0630a0f5612b29eaeaea8a585f8352 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:58:42 -0700 Subject: [PATCH 22/61] fix: pin SSH group leaders and anchor replacement discovery --- ...HForegroundAuthenticationRetryPolicy.swift | 78 ++++++++++++++----- 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index a6395b9d0278..eaf8fd13ba40 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -232,14 +232,24 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_group = $3 cmux_pid = $1 if ($4 ~ /Z/) next + cmux_started = $5 "_" $6 "_" $7 "_" $8 "_" $9 cmux_total[cmux_group]++ if (cmux_pid in cmux_tree) cmux_inside[cmux_group]++ + if (cmux_pid == cmux_group) { + cmux_leader_started[cmux_group] = cmux_started + cmux_leader_live[cmux_group] = 1 + cmux_leader_inside[cmux_group] = (cmux_pid in cmux_tree) + } } END { for (cmux_group in cmux_total) { if (cmux_group != "" && cmux_group != "0" && cmux_group != cmux_caller_group && - cmux_total[cmux_group] == cmux_inside[cmux_group]) print cmux_group + cmux_total[cmux_group] == cmux_inside[cmux_group] && + cmux_leader_live[cmux_group] && + cmux_leader_inside[cmux_group]) { + print cmux_group, cmux_group, cmux_leader_started[cmux_group] + } } } ' "$cmux_ssh_auth_members" "$cmux_ssh_auth_snapshot" > "$cmux_ssh_auth_groups" @@ -324,12 +334,12 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_ssh_auth_snapshot" >> "$cmux_ssh_auth_dynamic_groups" } - # A TERM handler in the generated authentication wrapper writes one - # root PID after it has waited for its child and completed its - # cleanup. The helper acknowledges only after the post-TERM process - # group snapshot, so the wrapper remains the parent while a new - # session leader is recorded. A bounded read keeps plain fixtures and - # failed wrappers from blocking cleanup. + # The generated authentication wrapper writes one root PID after it + # has sent TERM to its command, then waits for the helper ACK before + # it waits for that command and exits. This keeps the wrapper alive + # while the helper takes the post-TERM process-group snapshot. A + # bounded read keeps plain fixtures and failed wrappers from + # blocking cleanup. cmux_ssh_auth_wait_for_term_event() { [ "$cmux_ssh_auth_wait_for_term_event_enabled" = 1 ] || return 0 if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then return 0; fi @@ -596,12 +606,14 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { kill -TERM "$cmux_pid" >/dev/null 2>&1 || true kill -CONT "$cmux_pid" >/dev/null 2>&1 || true done < "$cmux_ssh_auth_term" - # Capture replacements while the TERM handler is still anchored to - # the original tree, then refresh once more after its completion - # event. The leader identity journal remains valid after reparenting. - cmux_ssh_auth_record_dynamic_groups || true + # The wrapper sends its event immediately after forwarding TERM and + # waits for our ACK. Snapshot before ACK so a replacement is still + # attached to the live wrapper even when its direct parent exits. cmux_ssh_auth_wait_for_term_event cmux_ssh_auth_record_dynamic_groups || true + # Refresh once more before releasing the wrapper. The leader + # identity journal remains valid after reparenting. + cmux_ssh_auth_record_dynamic_groups || true cmux_ssh_auth_ack_term_event # Rebuild ownership from exact identities, exclusive groups, and @@ -613,7 +625,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_owned_identity[$2 SUBSEP $4 SUBSEP $6] = 1 next } - FILENAME == ARGV[2] { cmux_exclusive_group[$1] = 1; next } + FILENAME == ARGV[2] { + cmux_exclusive_leader_pid[$1] = $2 + cmux_exclusive_leader_started[$1] = $3 + next + } FILENAME == ARGV[3] { cmux_dynamic_leader[$1] = $2 SUBSEP $3 next @@ -627,18 +643,39 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_row[cmux_pid] = cmux_pid " " $2 " " $3 " " $4 " " cmux_started[cmux_pid] cmux_process[cmux_pid] = 1 cmux_children[$2] = cmux_children[$2] " " cmux_pid - if ((cmux_pid SUBSEP $3 SUBSEP cmux_started[cmux_pid]) in cmux_owned_identity || - $3 in cmux_exclusive_group) { + if ((cmux_pid SUBSEP $3 SUBSEP cmux_started[cmux_pid]) in cmux_owned_identity) { cmux_seen[cmux_pid] = 1 cmux_queue[++cmux_queue_tail] = cmux_pid cmux_depth[cmux_pid] = 0 } - if (cmux_pid == $3 && - cmux_dynamic_leader[$3] == (cmux_pid SUBSEP cmux_started[cmux_pid])) { - cmux_dynamic_group[$3] = 1 - } } END { + # A process group ID is reusable. Accept the group-wide + # ownership path only while the original leader has the + # exact PID/start identity captured before TERM. + for (cmux_group_id in cmux_exclusive_leader_pid) { + cmux_leader_pid = cmux_exclusive_leader_pid[cmux_group_id] + cmux_leader_started = cmux_exclusive_leader_started[cmux_group_id] + if (cmux_leader_pid in cmux_process && + cmux_group[cmux_leader_pid] == cmux_group_id && + cmux_started[cmux_leader_pid] == cmux_leader_started && + cmux_state[cmux_leader_pid] !~ /Z/) { + cmux_valid_exclusive_group[cmux_group_id] = 1 + } + } + for (cmux_pid in cmux_process) { + if (cmux_valid_exclusive_group[cmux_group[cmux_pid]] && + !(cmux_pid in cmux_seen) && cmux_state[cmux_pid] !~ /Z/) { + cmux_seen[cmux_pid] = 1 + cmux_queue[++cmux_queue_tail] = cmux_pid + cmux_depth[cmux_pid] = 0 + } + if (cmux_pid == cmux_group[cmux_pid] && + cmux_dynamic_leader[cmux_group[cmux_pid]] == + (cmux_pid SUBSEP cmux_started[cmux_pid])) { + cmux_dynamic_group[cmux_group[cmux_pid]] = 1 + } + } # A dynamic group is valid only while its recorded leader # still has the exact PID/start identity. Once validated, # every current member of that group is owned, including @@ -810,6 +847,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "CMUX_SSH_AUTH_ROOT_PID=\"$$\"; export CMUX_SSH_AUTH_ROOT_PID", "cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${CMUX_SSH_AUTH_ROOT_PID}/done\"", "cmux_ssh_auth_term_event_ack_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${CMUX_SSH_AUTH_ROOT_PID}/ack\"", + // Notify the cleanup helper after forwarding TERM, then wait for + // its ACK. The helper uses this pause to snapshot replacements + // before the command handler can orphan them. "cmux_ssh_auth_signal_completion() { if [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then if exec 8<> \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null; then printf '%s\\n' \"$$\" >&8 2>/dev/null || true; exec 8>&-; fi; if [ -p \"$cmux_ssh_auth_term_event_ack_fifo\" ] && exec 8<> \"$cmux_ssh_auth_term_event_ack_fifo\" 2>/dev/null; then cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack <&8 || true; exec 8>&-; fi; fi; }", "cmux_ssh_auth_capture_cleanup() {", " if [ -n \"${cmux_ssh_auth_classifier_guard_fd:-}\" ]; then", @@ -830,11 +870,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { " trap - EXIT HUP INT TERM", " if [ -n \"${cmux_ssh_auth_command_pid:-}\" ]; then", " /bin/kill -\"$cmux_ssh_auth_capture_signal_name\" \"$cmux_ssh_auth_command_pid\" >/dev/null 2>&1 || true", + " cmux_ssh_auth_signal_completion", " wait \"$cmux_ssh_auth_command_pid\" 2>/dev/null || true", " cmux_ssh_auth_command_pid=", " fi", " cmux_ssh_auth_capture_cleanup", - " cmux_ssh_auth_signal_completion", " exit \"$cmux_ssh_auth_capture_signal_status\"", "}", "trap 'cmux_ssh_auth_capture_cleanup' EXIT", From 3598af9fa730721686c3d73bb9e6dff56dcaa86d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:29:28 -0700 Subject: [PATCH 23/61] test: require identity-safe SSH replacement cleanup --- ...groundAuthenticationRetryPolicyTests.swift | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index b42d93bdfb7b..443a4f813500 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -294,13 +294,14 @@ struct SSHForegroundAuthenticationRetryPolicyTests { )) defer { Darwin.kill(leafPID, SIGKILL) } let exitDeadline = Date.now.addingTimeInterval(1) - while Darwin.kill(leafPID, 0) == 0, Date.now < exitDeadline { + while processLiveness(leafPID) == .live, Date.now < exitDeadline { Thread.sleep(forTimeInterval: 0.01) } #expect(process.terminationStatus == 0) #expect(try String(contentsOf: signalLog, encoding: .utf8) == "term\n") - #expect(Darwin.kill(leafPID, 0) != 0) + #expect(processLiveness(leafPID) == .terminated) + #expect(processLiveness(leafPID) != .unknown) } @Test func refusesAuthenticationRootWithMismatchedKnownParent() throws { @@ -435,13 +436,15 @@ struct SSHForegroundAuthenticationRetryPolicyTests { .split(separator: "\n") .compactMap { Int32($0) } let exitDeadline = Date.now.addingTimeInterval(1) - while processIDs.contains(where: isLiveProcess), Date.now < exitDeadline { + while processIDs.contains(where: { processLiveness($0) == .live }), Date.now < exitDeadline { Thread.sleep(forTimeInterval: 0.01) } #expect(process.terminationStatus == 0) #expect(processIDs.count == 25) - #expect(!processIDs.contains(where: isLiveProcess)) + let processStates = processIDs.map(processLiveness) + #expect(!processStates.contains(.unknown)) + #expect(!processStates.contains(.live)) } @Test func terminatesReplacementSpawnedByAuthenticationTermHandler() throws { @@ -452,6 +455,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { let replacementScript = root.appendingPathComponent("replacement.sh") let setIDLauncher = root.appendingPathComponent("setid-launcher.pl") let replacementPIDFile = root.appendingPathComponent("replacement.pid") + let eventToken = UUID().uuidString.lowercased() try fileManager.createDirectory(at: root, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: root) } @@ -479,10 +483,11 @@ struct SSHForegroundAuthenticationRetryPolicyTests { trap 'trap "" TERM; /usr/bin/perl "$CMUX_TEST_SETID_LAUNCHER" /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; exit 143' TERM : > "$CMUX_TEST_READY_MARKER" while :; do /bin/sleep 30; done - """ + """ ) let command = """ \(policy.processTreeTerminationShellFunction()) + CMUX_SSH_AUTH_EVENT_TOKEN=\(eventToken); export CMUX_SSH_AUTH_EVENT_TOKEN ( \(classifiedAuthentication) ) & cmux_test_auth_root=$! cmux_test_ready_attempt=0 @@ -491,7 +496,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { cmux_test_ready_attempt=$((cmux_test_ready_attempt + 1)) done test -f "$CMUX_TEST_READY_MARKER" || exit 98 - cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" 1 + cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" 1 "\(eventToken)" wait "$cmux_test_auth_root" 2>/dev/null || true cmux_test_replacement_attempt=0 while [ ! -s "$CMUX_TEST_REPLACEMENT_PID" ] && [ "$cmux_test_replacement_attempt" -lt 100 ]; do @@ -525,12 +530,13 @@ struct SSHForegroundAuthenticationRetryPolicyTests { )) defer { Darwin.kill(replacementPID, SIGKILL) } let exitDeadline = Date.now.addingTimeInterval(1) - while Darwin.kill(replacementPID, 0) == 0, Date.now < exitDeadline { + while processLiveness(replacementPID) == .live, Date.now < exitDeadline { Thread.sleep(forTimeInterval: 0.01) } #expect(process.terminationStatus == 0) - #expect(Darwin.kill(replacementPID, 0) != 0) + #expect(processLiveness(replacementPID) == .terminated) + #expect(processLiveness(replacementPID) != .unknown) } @Test func restoresTerminalModesWhenTerminatingForegroundAuthenticationTree() throws { @@ -762,10 +768,17 @@ struct SSHForegroundAuthenticationRetryPolicyTests { } } - private func isLiveProcess(_ processID: Int32) -> Bool { + private enum ProcessLiveness: Equatable { + case live + case terminated + case unknown + } + + private func processLiveness(_ processID: Int32) -> ProcessLiveness { // kill(pid, 0) also succeeds for zombies. The cleanup helper treats a // zombie as terminated, so inspect process state before reporting a - // survivor. + // survivor. An unexpected proc_pidinfo result is unknown, not proof of + // termination. var info = proc_bsdinfo() let expectedSize = MemoryLayout.stride let size = proc_pidinfo( @@ -775,7 +788,12 @@ struct SSHForegroundAuthenticationRetryPolicyTests { &info, Int32(expectedSize) ) - guard Int(size) == expectedSize else { return false } - return info.pbi_status != UInt32(SZOMB) + if Int(size) == expectedSize { + return info.pbi_status == UInt32(SZOMB) ? .terminated : .live + } + if size == 0 && errno == ESRCH { + return .terminated + } + return .unknown } } From 8a974eb9b5b11862956b00b4e90824ca0facbc5f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:31:21 -0700 Subject: [PATCH 24/61] fix: anchor SSH cleanup to attempt identities --- ...HForegroundAuthenticationRetryPolicy.swift | 285 +++++++----------- .../SSHPTYAttachRetryScriptBuilder.swift | 6 +- .../SSHPTYAttachStartupCommandBuilder.swift | 3 +- 3 files changed, 120 insertions(+), 174 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index eaf8fd13ba40..04e50ae0ae21 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -93,12 +93,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { /// accepted record carries its PID, process group, and `ps lstart` identity. /// A second snapshot must confirm the identity and stopped state before the /// helper sends `SIGKILL`. After `SIGTERM`, the helper waits for the - /// PID-scoped TERM completion FIFO emitted by the authentication wrapper, - /// then re-discovers descendants and exclusive process groups so a - /// handler-spawned replacement remains owned. Failed snapshots never trigger - /// an unverified kill; the EXIT path resumes stopped identities, including a - /// PID whose identity changed after a failed stop. This keeps cleanup bounded - /// when the runner cannot fork and avoids killing a reused PID. + /// per-attempt completion FIFO emitted by the authentication wrapper, then + /// records descendants that still hold the attempt's marker descriptor. + /// Failed snapshots never trigger an unverified signal. This keeps cleanup + /// bounded when the runner cannot fork and avoids killing a reused PID. /// /// - Returns: A shell function named `cmux_ssh_terminate_auth_process_tree`. public func processTreeTerminationShellFunction() -> String { @@ -134,7 +132,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_members="$cmux_ssh_auth_state_dir/members" cmux_ssh_auth_pending="$cmux_ssh_auth_state_dir/pending" cmux_ssh_auth_owned="$cmux_ssh_auth_state_dir/owned" - cmux_ssh_auth_groups="$cmux_ssh_auth_state_dir/groups" cmux_ssh_auth_live="$cmux_ssh_auth_state_dir/live" cmux_ssh_auth_term="$cmux_ssh_auth_state_dir/term" cmux_ssh_auth_term_candidates="$cmux_ssh_auth_state_dir/term-candidates" @@ -142,17 +139,31 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_kill_candidates="$cmux_ssh_auth_state_dir/kill-candidates" cmux_ssh_auth_root_identity_file="$cmux_ssh_auth_state_dir/root-identity" cmux_ssh_auth_root_identity_candidate="$cmux_ssh_auth_state_dir/root-identity-candidate" - cmux_ssh_auth_dynamic_groups="$cmux_ssh_auth_state_dir/dynamic-groups" + cmux_ssh_auth_dynamic_members="$cmux_ssh_auth_state_dir/dynamic-members" + cmux_ssh_auth_marker_holders="$cmux_ssh_auth_state_dir/marker-holders" cmux_ssh_auth_root_identity= - cmux_ssh_auth_term_event_dir="${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_tree_root_pid" - cmux_ssh_auth_term_event_fifo="$cmux_ssh_auth_term_event_dir/done" - cmux_ssh_auth_term_event_ack_fifo="$cmux_ssh_auth_term_event_dir/ack" + cmux_ssh_auth_event_token="${4:-${CMUX_SSH_AUTH_EVENT_TOKEN:-}}" + case "$cmux_ssh_auth_event_token" in + ''|*[!A-Za-z0-9_-]*) cmux_ssh_auth_event_token= ;; + esac + cmux_ssh_auth_term_event_dir= + if [ -n "$cmux_ssh_auth_event_token" ]; then + cmux_ssh_auth_term_event_dir="${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token" + fi + cmux_ssh_auth_term_event_fifo= + cmux_ssh_auth_term_event_ack_fifo= + cmux_ssh_auth_marker_path= + if [ -n "$cmux_ssh_auth_event_token" ]; then + cmux_ssh_auth_marker_path="${TMPDIR:-/tmp}/cmux-ssh-auth-marker.$cmux_ssh_auth_event_token" + cmux_ssh_auth_term_event_fifo="$cmux_ssh_auth_term_event_dir/done" + cmux_ssh_auth_term_event_ack_fifo="$cmux_ssh_auth_term_event_dir/ack" + fi cmux_ssh_auth_term_event_owned=0 cmux_ssh_auth_term_event_received=0 - cmux_ssh_auth_caller_group_file="$cmux_ssh_auth_state_dir/caller-group" : > "$cmux_ssh_auth_owned" || exit 0 : > "$cmux_ssh_auth_pending" || exit 0 - : > "$cmux_ssh_auth_dynamic_groups" || exit 0 + : > "$cmux_ssh_auth_dynamic_members" || exit 0 + : > "$cmux_ssh_auth_marker_holders" || exit 0 cmux_ssh_auth_take_snapshot() { /bin/ps -axo pid=,ppid=,pgid=,state=,lstart= > "$cmux_ssh_auth_snapshot" 2>/dev/null @@ -160,13 +171,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_extract_tree() { : > "$cmux_ssh_auth_members" - : > "$cmux_ssh_auth_groups" : > "$cmux_ssh_auth_root_identity_candidate" /usr/bin/awk \ -v cmux_root="$cmux_ssh_auth_tree_root_pid" \ -v cmux_root_parent="$cmux_ssh_auth_tree_root_parent" \ - -v cmux_root_identity_candidate="$cmux_ssh_auth_root_identity_candidate" \ - -v cmux_caller_group_file="$cmux_ssh_auth_caller_group_file" ' + -v cmux_root_identity_candidate="$cmux_ssh_auth_root_identity_candidate" ' NF >= 9 { cmux_pid = $1 cmux_parent[cmux_pid] = $2 @@ -206,8 +215,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_queue[++cmux_queue_tail] = cmux_child_pid } } - cmux_caller_group = cmux_group[cmux_root_parent] - if (cmux_caller_group != "") print cmux_caller_group > cmux_caller_group_file } ' "$cmux_ssh_auth_snapshot" > "$cmux_ssh_auth_members" cmux_ssh_auth_extract_status=$? @@ -222,37 +229,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { elif [ "$cmux_ssh_auth_root_identity" != "$cmux_ssh_auth_root_identity_candidate_value" ]; then return 1 fi - cmux_ssh_auth_caller_group="" - if [ -s "$cmux_ssh_auth_caller_group_file" ]; then - IFS= read -r cmux_ssh_auth_caller_group < "$cmux_ssh_auth_caller_group_file" - fi - /usr/bin/awk -v cmux_caller_group="$cmux_ssh_auth_caller_group" ' - FILENAME == ARGV[1] { cmux_tree[$2] = 1; next } - NF >= 9 { - cmux_group = $3 - cmux_pid = $1 - if ($4 ~ /Z/) next - cmux_started = $5 "_" $6 "_" $7 "_" $8 "_" $9 - cmux_total[cmux_group]++ - if (cmux_pid in cmux_tree) cmux_inside[cmux_group]++ - if (cmux_pid == cmux_group) { - cmux_leader_started[cmux_group] = cmux_started - cmux_leader_live[cmux_group] = 1 - cmux_leader_inside[cmux_group] = (cmux_pid in cmux_tree) - } - } - END { - for (cmux_group in cmux_total) { - if (cmux_group != "" && cmux_group != "0" && - cmux_group != cmux_caller_group && - cmux_total[cmux_group] == cmux_inside[cmux_group] && - cmux_leader_live[cmux_group] && - cmux_leader_inside[cmux_group]) { - print cmux_group, cmux_group, cmux_leader_started[cmux_group] - } - } - } - ' "$cmux_ssh_auth_members" "$cmux_ssh_auth_snapshot" > "$cmux_ssh_auth_groups" } cmux_ssh_auth_append_pending() { @@ -263,24 +239,28 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { : > "$cmux_ssh_auth_pending" || return 1 } - # A TERM handler can create a new session or process group before it - # exits. Record only a live group leader that is still descended from - # an identity in the original tree. The leader PID and start tuple - # form an identity fence, so a later reuse of the numeric PGID is not - # enough to make an unrelated group owned. - cmux_ssh_auth_record_dynamic_groups() { + # A TERM handler can create a new session, exit, and leave its + # replacement reparented before the next process-table snapshot. The + # classifier therefore keeps a per-attempt marker FD open. `lsof` + # returns the exact processes that inherited that FD, including a + # detached replacement. Record their PID, PGID, and start identity, + # then follow only their current descendants. A random marker token + # and the inherited descriptor are the ownership proof; no numeric + # process-group reuse can authorize an unrelated process. + cmux_ssh_auth_record_dynamic_members() { cmux_ssh_auth_take_snapshot || return 1 + : > "$cmux_ssh_auth_marker_holders" || return 1 + if [ -n "$cmux_ssh_auth_marker_path" ] && [ -f "$cmux_ssh_auth_marker_path" ]; then + /usr/sbin/lsof -n -w -t -- "$cmux_ssh_auth_marker_path" \ + > "$cmux_ssh_auth_marker_holders" 2>/dev/null || : > "$cmux_ssh_auth_marker_holders" + fi /usr/bin/awk ' FILENAME == ARGV[1] { cmux_original_identity[$2 SUBSEP $6] = 1 next } FILENAME == ARGV[2] { - cmux_original_group[$1] = 1 - next - } - FILENAME == ARGV[3] { - cmux_caller_group = $1 + if ($1 ~ /^[0-9]+$/) cmux_marker[$1] = 1 next } NF >= 9 { @@ -293,12 +273,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_children[$2] = cmux_children[$2] " " cmux_pid } END { - # Start with every current process whose PID/start identity - # was present in the original tree, then walk current child - # edges once. This validates parentage without trusting a PID - # after it has been reused. - for (cmux_pid in cmux_process) { - if ((cmux_pid SUBSEP cmux_started[cmux_pid]) in cmux_original_identity) { + # Marker holders are the roots of the post-TERM lineage. The + # child walk remains identity-anchored to this one snapshot. + for (cmux_pid in cmux_marker) { + if (cmux_pid in cmux_process && cmux_state[cmux_pid] !~ /Z/) { cmux_lineage[cmux_pid] = 1 cmux_queue[++cmux_queue_tail] = cmux_pid } @@ -317,21 +295,16 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_queue[++cmux_queue_tail] = cmux_child_pid } } - for (cmux_pid in cmux_process) { - cmux_group_id = cmux_group[cmux_pid] + for (cmux_pid in cmux_lineage) { cmux_started_id = cmux_started[cmux_pid] - if (cmux_state[cmux_pid] ~ /Z/ || cmux_pid != cmux_group_id || - cmux_group_id == "" || cmux_group_id == "0" || - cmux_group_id == cmux_caller_group || - cmux_group_id in cmux_original_group || - !(cmux_pid in cmux_lineage) || - (cmux_pid SUBSEP cmux_started_id) in cmux_original_identity) continue - print cmux_group_id, cmux_pid, cmux_started_id + if (cmux_state[cmux_pid] !~ /Z/ && + !((cmux_pid SUBSEP cmux_started_id) in cmux_original_identity)) { + print cmux_pid, cmux_group[cmux_pid], cmux_started_id + } } } - ' "$cmux_ssh_auth_members" "$cmux_ssh_auth_groups" \ - "$cmux_ssh_auth_caller_group_file" \ - "$cmux_ssh_auth_snapshot" >> "$cmux_ssh_auth_dynamic_groups" + ' "$cmux_ssh_auth_members" "$cmux_ssh_auth_marker_holders" \ + "$cmux_ssh_auth_snapshot" >> "$cmux_ssh_auth_dynamic_members" } # The generated authentication wrapper writes one root PID after it @@ -342,6 +315,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # blocking cleanup. cmux_ssh_auth_wait_for_term_event() { [ "$cmux_ssh_auth_wait_for_term_event_enabled" = 1 ] || return 0 + [ -n "$cmux_ssh_auth_event_token" ] || return 0 if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 cmux_ssh_auth_term_event_writer= @@ -349,7 +323,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # waits are bounded by the same overall cleanup deadline. while cmux_ssh_auth_term_wait_has_time; do if IFS= read -r -t 1 cmux_ssh_auth_term_event_writer <&9; then - if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_tree_root_pid" ]; then + # The FIFO directory and payload both carry the random, + # per-attempt nonce. Process ownership is established by the + # marker FD journal, not by a PID that can be reused. + if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_event_token" ]; then cmux_ssh_auth_term_event_received=1 break fi @@ -362,21 +339,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_ack_term_event() { if [ "$cmux_ssh_auth_term_event_received" = 1 ]; then - printf '%s\n' "$cmux_ssh_auth_tree_root_pid" >&10 2>/dev/null || true + printf '%s\n' "$cmux_ssh_auth_event_token" >&10 2>/dev/null || true fi exec 9>&- } - cmux_ssh_auth_resume_journal_directly() { - cmux_ssh_auth_resume_path="$1" - while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do - case "$cmux_pid" in ''|*[!0-9]*) continue ;; esac - # This path is CONT-only. The journal records a successful STOP - # from this helper, so recovery must not depend on another fork. - kill -CONT "$cmux_pid" >/dev/null 2>&1 || true - done < "$cmux_ssh_auth_resume_path" - } - # A successful STOP pins a process in place. If the identity check # fails, resume every current process with the recorded PID that is # either a different identity or no longer stopped. This undoes a @@ -385,7 +352,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_resume_path="$1" [ -s "$cmux_ssh_auth_resume_path" ] || return 0 if ! cmux_ssh_auth_take_snapshot; then - cmux_ssh_auth_resume_journal_directly "$cmux_ssh_auth_resume_path" + # A missing snapshot cannot distinguish the journal PID from a + # reused PID. Leave the process stopped rather than signaling an + # unverified identity. return 0 fi if ! cmux_ssh_auth_resume_pids=$( @@ -404,7 +373,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { } ' "$cmux_ssh_auth_resume_path" "$cmux_ssh_auth_snapshot" ); then - cmux_ssh_auth_resume_journal_directly "$cmux_ssh_auth_resume_path" return 0 fi for cmux_ssh_auth_resume_pid in $cmux_ssh_auth_resume_pids; do @@ -454,7 +422,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_resume_path="$1" [ -s "$cmux_ssh_auth_resume_path" ] || return 0 if ! cmux_ssh_auth_take_snapshot; then - cmux_ssh_auth_resume_journal_directly "$cmux_ssh_auth_resume_path" return 0 fi if ! cmux_ssh_auth_resume_pids=$( @@ -469,7 +436,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { } ' "$cmux_ssh_auth_resume_path" "$cmux_ssh_auth_snapshot" ); then - cmux_ssh_auth_resume_journal_directly "$cmux_ssh_auth_resume_path" return 0 fi for cmux_ssh_auth_resume_pid in $cmux_ssh_auth_resume_pids; do @@ -495,12 +461,12 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { exec 9>&- 2>/dev/null || true exec 10>&- 2>/dev/null || true /bin/rm -f "$cmux_ssh_auth_snapshot" "$cmux_ssh_auth_members" \ - "$cmux_ssh_auth_pending" "$cmux_ssh_auth_owned" "$cmux_ssh_auth_groups" \ + "$cmux_ssh_auth_pending" "$cmux_ssh_auth_owned" \ "$cmux_ssh_auth_live" "$cmux_ssh_auth_term" \ "$cmux_ssh_auth_term_candidates" "$cmux_ssh_auth_stop_candidates" \ - "$cmux_ssh_auth_kill_candidates" "$cmux_ssh_auth_caller_group_file" \ + "$cmux_ssh_auth_kill_candidates" \ "$cmux_ssh_auth_root_identity_file" "$cmux_ssh_auth_root_identity_candidate" \ - "$cmux_ssh_auth_dynamic_groups" \ + "$cmux_ssh_auth_dynamic_members" "$cmux_ssh_auth_marker_holders" \ 2>/dev/null || true /bin/rmdir "$cmux_ssh_auth_state_dir" 2>/dev/null || true } @@ -512,23 +478,28 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # Validate the known root parent and build the first breadth-first # member list. The root is stopped first in that order. if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_tree; then exit 0; fi - # The authentication wrapper derives this same path from its parent - # PID. A pre-existing path is never removed or reused, so a stale - # process cannot receive an event from this cleanup attempt. - if [ "$cmux_ssh_auth_wait_for_term_event_enabled" = 1 ] && \ - /bin/mkdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null && \ - /usr/bin/mkfifo "$cmux_ssh_auth_term_event_fifo" 2>/dev/null && \ - /usr/bin/mkfifo "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null && \ - exec 9<> "$cmux_ssh_auth_term_event_fifo" 2>/dev/null && \ - exec 10<> "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null; then - cmux_ssh_auth_term_event_owned=1 - else - exec 9>&- 2>/dev/null || true - exec 10>&- 2>/dev/null || true - /bin/rm -f "$cmux_ssh_auth_term_event_fifo" "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null || true - /bin/rmdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null || true - cmux_ssh_auth_term_event_fifo= - cmux_ssh_auth_term_event_ack_fifo= + # The authentication wrapper derives this same path from the fresh + # per-attempt nonce. A pre-existing path is never removed or reused, + # so a stale process cannot receive an event from this attempt. + if [ "$cmux_ssh_auth_wait_for_term_event_enabled" = 1 ] && + [ -n "$cmux_ssh_auth_event_token" ] && + /bin/mkdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null; then + if /usr/bin/mkfifo "$cmux_ssh_auth_term_event_fifo" 2>/dev/null && \ + /usr/bin/mkfifo "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null && \ + exec 9<> "$cmux_ssh_auth_term_event_fifo" 2>/dev/null && \ + exec 10<> "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null; then + cmux_ssh_auth_term_event_owned=1 + else + exec 9>&- 2>/dev/null || true + exec 10>&- 2>/dev/null || true + # The directory was created by this invocation. Remove only its + # own partial setup. A mkdir collision never reaches this path, + # so a stale attempt's FIFOs remain untouched. + /bin/rm -f "$cmux_ssh_auth_term_event_fifo" "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null || true + /bin/rmdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null || true + cmux_ssh_auth_term_event_fifo= + cmux_ssh_auth_term_event_ack_fifo= + fi fi cmux_ssh_auth_freeze_attempt=0 cmux_ssh_auth_tree_frozen=0 @@ -610,14 +581,15 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # waits for our ACK. Snapshot before ACK so a replacement is still # attached to the live wrapper even when its direct parent exits. cmux_ssh_auth_wait_for_term_event - cmux_ssh_auth_record_dynamic_groups || true - # Refresh once more before releasing the wrapper. The leader + cmux_ssh_auth_record_dynamic_members || true + # Refresh once more before releasing the wrapper. The marker-FD # identity journal remains valid after reparenting. - cmux_ssh_auth_record_dynamic_groups || true + cmux_ssh_auth_record_dynamic_members || true cmux_ssh_auth_ack_term_event - # Rebuild ownership from exact identities, exclusive groups, and - # descendants. This catches a replacement that outlives its parent. + # Rebuild ownership from exact identities and descendants. Marker-FD + # identities catch a replacement that outlives its parent without + # broadening ownership to unrelated process-group members. cmux_ssh_auth_extract_owned() { : > "$cmux_ssh_auth_live" /usr/bin/awk ' @@ -626,12 +598,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { next } FILENAME == ARGV[2] { - cmux_exclusive_leader_pid[$1] = $2 - cmux_exclusive_leader_started[$1] = $3 - next - } - FILENAME == ARGV[3] { - cmux_dynamic_leader[$1] = $2 SUBSEP $3 + if ($1 ~ /^[0-9]+$/ && $2 ~ /^[0-9]+$/ && $3 != "") { + cmux_dynamic_identity[$1 SUBSEP $2 SUBSEP $3] = 1 + } next } NF >= 9 { @@ -643,51 +612,20 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_row[cmux_pid] = cmux_pid " " $2 " " $3 " " $4 " " cmux_started[cmux_pid] cmux_process[cmux_pid] = 1 cmux_children[$2] = cmux_children[$2] " " cmux_pid - if ((cmux_pid SUBSEP $3 SUBSEP cmux_started[cmux_pid]) in cmux_owned_identity) { + cmux_identity_key = cmux_pid SUBSEP $3 SUBSEP cmux_started[cmux_pid] + if (cmux_identity_key in cmux_owned_identity || + cmux_identity_key in cmux_dynamic_identity) { cmux_seen[cmux_pid] = 1 cmux_queue[++cmux_queue_tail] = cmux_pid cmux_depth[cmux_pid] = 0 } } END { - # A process group ID is reusable. Accept the group-wide - # ownership path only while the original leader has the - # exact PID/start identity captured before TERM. - for (cmux_group_id in cmux_exclusive_leader_pid) { - cmux_leader_pid = cmux_exclusive_leader_pid[cmux_group_id] - cmux_leader_started = cmux_exclusive_leader_started[cmux_group_id] - if (cmux_leader_pid in cmux_process && - cmux_group[cmux_leader_pid] == cmux_group_id && - cmux_started[cmux_leader_pid] == cmux_leader_started && - cmux_state[cmux_leader_pid] !~ /Z/) { - cmux_valid_exclusive_group[cmux_group_id] = 1 - } - } - for (cmux_pid in cmux_process) { - if (cmux_valid_exclusive_group[cmux_group[cmux_pid]] && - !(cmux_pid in cmux_seen) && cmux_state[cmux_pid] !~ /Z/) { - cmux_seen[cmux_pid] = 1 - cmux_queue[++cmux_queue_tail] = cmux_pid - cmux_depth[cmux_pid] = 0 - } - if (cmux_pid == cmux_group[cmux_pid] && - cmux_dynamic_leader[cmux_group[cmux_pid]] == - (cmux_pid SUBSEP cmux_started[cmux_pid])) { - cmux_dynamic_group[cmux_group[cmux_pid]] = 1 - } - } - # A dynamic group is valid only while its recorded leader - # still has the exact PID/start identity. Once validated, - # every current member of that group is owned, including - # siblings that are not descendants of the leader. - for (cmux_pid in cmux_process) { - if (cmux_group[cmux_pid] in cmux_dynamic_group && - !(cmux_pid in cmux_seen) && cmux_state[cmux_pid] !~ /Z/) { - cmux_seen[cmux_pid] = 1 - cmux_queue[++cmux_queue_tail] = cmux_pid - cmux_depth[cmux_pid] = 0 - } - } + # Ownership is identity-based only. Do not expand a process + # group by numeric PGID: the ID can be reused, and an + # unrelated same-session member must never enter a signal + # batch. Dynamic replacements are seeded by their marker-FD + # identity and then followed through current child edges. cmux_queue_head = 1 while (cmux_queue_head <= cmux_queue_tail) { cmux_parent_pid = cmux_queue[cmux_queue_head++] @@ -707,7 +645,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { print cmux_depth[cmux_pid], cmux_row[cmux_pid] } } - ' "$cmux_ssh_auth_owned" "$cmux_ssh_auth_groups" "$cmux_ssh_auth_dynamic_groups" \ + ' "$cmux_ssh_auth_owned" "$cmux_ssh_auth_dynamic_members" \ "$cmux_ssh_auth_snapshot" > "$cmux_ssh_auth_live" } @@ -841,16 +779,18 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "cmux_ssh_auth_classifier_guard_fd=", "cmux_ssh_auth_classifier_pid=", "cmux_ssh_auth_command_pid=", - // The classifier is the process-tree root passed to the cleanup - // helper. Use its own PID for the per-attempt event path. Nested - // commands keep their own PID and cannot publish this event. - "CMUX_SSH_AUTH_ROOT_PID=\"$$\"; export CMUX_SSH_AUTH_ROOT_PID", - "cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${CMUX_SSH_AUTH_ROOT_PID}/done\"", - "cmux_ssh_auth_term_event_ack_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.${CMUX_SSH_AUTH_ROOT_PID}/ack\"", + // The retry supervisor allocates a fresh nonce before launching + // this classifier. It is shared with the cleanup helper through + // the parent environment, so nested shells never derive an event + // path from their unrelated `$$` values. + "cmux_ssh_auth_event_token=\"${CMUX_SSH_AUTH_EVENT_TOKEN:-}\"", + "case \"$cmux_ssh_auth_event_token\" in ''|*[!A-Za-z0-9_-]*) cmux_ssh_auth_event_token= ;; esac", + "cmux_ssh_auth_term_event_fifo=; cmux_ssh_auth_term_event_ack_fifo=; cmux_ssh_auth_marker_path=; cmux_ssh_auth_marker_owned=0", + "if [ -n \"$cmux_ssh_auth_event_token\" ]; then cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/done\"; cmux_ssh_auth_term_event_ack_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/ack\"; cmux_ssh_auth_marker_path=\"${TMPDIR:-/tmp}/cmux-ssh-auth-marker.$cmux_ssh_auth_event_token\"; if ( set -C; : > \"$cmux_ssh_auth_marker_path\" ) 2>/dev/null; then if exec 7<> \"$cmux_ssh_auth_marker_path\" 2>/dev/null; then cmux_ssh_auth_marker_owned=1; else /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true; fi; fi; fi", // Notify the cleanup helper after forwarding TERM, then wait for // its ACK. The helper uses this pause to snapshot replacements // before the command handler can orphan them. - "cmux_ssh_auth_signal_completion() { if [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then if exec 8<> \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null; then printf '%s\\n' \"$$\" >&8 2>/dev/null || true; exec 8>&-; fi; if [ -p \"$cmux_ssh_auth_term_event_ack_fifo\" ] && exec 8<> \"$cmux_ssh_auth_term_event_ack_fifo\" 2>/dev/null; then cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack <&8 || true; exec 8>&-; fi; fi; }", + "cmux_ssh_auth_signal_completion() { if [ -n \"$cmux_ssh_auth_event_token\" ] && [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then if exec 8<> \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null; then printf '%s\\n' \"$cmux_ssh_auth_event_token\" >&8 2>/dev/null || true; exec 8>&-; fi; if [ -p \"$cmux_ssh_auth_term_event_ack_fifo\" ] && exec 8<> \"$cmux_ssh_auth_term_event_ack_fifo\" 2>/dev/null; then cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack <&8 || true; exec 8>&-; fi; fi; }", "cmux_ssh_auth_capture_cleanup() {", " if [ -n \"${cmux_ssh_auth_classifier_guard_fd:-}\" ]; then", " exec {cmux_ssh_auth_classifier_guard_fd}>&-", @@ -862,6 +802,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { " wait \"$cmux_ssh_auth_capture_pid\" 2>/dev/null || true", " fi", " done", + " if [ \"${cmux_ssh_auth_marker_owned:-0}\" = 1 ]; then exec 7>&-; /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true; cmux_ssh_auth_marker_owned=0; fi", " /bin/rm -f -- \"$cmux_ssh_auth_classifier_fifo\" \"$cmux_ssh_auth_capture_state\" 2>/dev/null || true", "}", "cmux_ssh_auth_capture_signal_exit() {", diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift index aeccb3987f95..6ba0148ac609 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift @@ -99,6 +99,7 @@ public struct SSHPTYAttachRetryScriptBuilder: Sendable { "cmux_ssh_attach_auth_succeeded=0", "cmux_ssh_attach_reauth_required=\(initialReauthentication)", "cmux_ssh_attach_auth_launching=0", + "cmux_ssh_attach_auth_event_token=", "CMUX_SSH_PTY_ATTACH_MANAGED_RECONNECT=1", "export CMUX_SSH_PTY_ATTACH_MANAGED_RECONNECT", ]) @@ -110,11 +111,14 @@ public struct SSHPTYAttachRetryScriptBuilder: Sendable { "while :; do", " if [ \"$cmux_ssh_attach_reauth_required\" -eq 1 ]; then", " cmux_ssh_attach_auth_launching=1", + " cmux_ssh_attach_auth_event_token=$(/usr/bin/uuidgen 2>/dev/null | /usr/bin/tr '[:upper:]' '[:lower:]' || true)", + " case \"$cmux_ssh_attach_auth_event_token\" in ''|*[!A-Za-z0-9_-]*) cmux_ssh_attach_auth_event_token= ;; esac", + " CMUX_SSH_AUTH_EVENT_TOKEN=\"$cmux_ssh_attach_auth_event_token\"; export CMUX_SSH_AUTH_EVENT_TOKEN", " ( cmux_ssh_attach_foreground_auth ) <&0 &", " cmux_ssh_attach_auth_pid=$!", " cmux_ssh_attach_auth_launching=0", " if [ -n \"${cmux_ssh_attach_pending_signal:-}\" ]; then cmux_ssh_attach_signal_exit \"$cmux_ssh_attach_pending_signal\" \"${cmux_ssh_attach_pending_signal_name:-TERM}\"; fi", - " wait \"$cmux_ssh_attach_auth_pid\"; cmux_ssh_attach_status=$?; cmux_ssh_attach_auth_pid=", + " wait \"$cmux_ssh_attach_auth_pid\"; cmux_ssh_attach_status=$?; cmux_ssh_attach_auth_pid=; cmux_ssh_attach_auth_event_token=; unset CMUX_SSH_AUTH_EVENT_TOKEN", " \(authenticationResult)", " case \"$cmux_ssh_attach_status\" in 254) cmux_ssh_attach_retry_reason=\(hostUnreachableReason) ;; 252) cmux_ssh_attach_retry_reason=\(controlMasterReason) ;; esac", " fi", diff --git a/Sources/SSHPTYAttachStartupCommandBuilder.swift b/Sources/SSHPTYAttachStartupCommandBuilder.swift index 9608e8e8565b..977af97da1cf 100644 --- a/Sources/SSHPTYAttachStartupCommandBuilder.swift +++ b/Sources/SSHPTYAttachStartupCommandBuilder.swift @@ -60,8 +60,9 @@ enum SSHPTYAttachStartupCommandBuilder { lines += [ "cmux_ssh_attach_lifecycle_ended=0", "cmux_ssh_attach_auth_pid=", + "cmux_ssh_attach_auth_event_token=", "cmux_ssh_attach_lifecycle_end() { if [ \"$cmux_ssh_attach_lifecycle_ended\" = 1 ]; then return; fi; cmux_ssh_attach_lifecycle_ended=1; \"$cmux_ssh_attach_cli\" --socket \"$CMUX_SOCKET_PATH\" ssh-session-end --lifecycle-only --workspace \"$CMUX_WORKSPACE_ID\" --surface \"${CMUX_SURFACE_ID:-}\" --terminal-lifecycle-id \"${CMUX_TERMINAL_LIFECYCLE_ID:-}\" --session-id \"$cmux_ssh_attach_session_id\" --lifecycle-id \"$cmux_ssh_attach_lifecycle_id\" >/dev/null 2>&1 || true; }", - "cmux_ssh_attach_signal_exit() { cmux_ssh_attach_signal_status=\"$1\"; cmux_ssh_attach_signal_name=\"$2\"; if [ -n \"${cmux_ssh_attach_auth_pid:-}\" ]; then cmux_ssh_terminate_auth_process_tree \"$cmux_ssh_attach_auth_pid\" \"$$\" 1; wait \"$cmux_ssh_attach_auth_pid\" 2>/dev/null || true; cmux_ssh_attach_auth_pid=; \(backoffBuilder.signalHandlerBranches) elif [ \"${cmux_ssh_attach_auth_launching:-0}\" = 1 ]; then cmux_ssh_attach_pending_signal=\"$cmux_ssh_attach_signal_status\"; cmux_ssh_attach_pending_signal_name=\"$cmux_ssh_attach_signal_name\"; return; fi; cmux_ssh_attach_restore_terminal; trap - EXIT HUP INT TERM; cmux_ssh_attach_lifecycle_end; exit \"$cmux_ssh_attach_signal_status\"; }", + "cmux_ssh_attach_signal_exit() { cmux_ssh_attach_signal_status=\"$1\"; cmux_ssh_attach_signal_name=\"$2\"; if [ -n \"${cmux_ssh_attach_auth_pid:-}\" ]; then cmux_ssh_terminate_auth_process_tree \"$cmux_ssh_attach_auth_pid\" \"$$\" 1 \"${cmux_ssh_attach_auth_event_token:-}\"; wait \"$cmux_ssh_attach_auth_pid\" 2>/dev/null || true; cmux_ssh_attach_auth_pid=; \(backoffBuilder.signalHandlerBranches) elif [ \"${cmux_ssh_attach_auth_launching:-0}\" = 1 ]; then cmux_ssh_attach_pending_signal=\"$cmux_ssh_attach_signal_status\"; cmux_ssh_attach_pending_signal_name=\"$cmux_ssh_attach_signal_name\"; return; fi; cmux_ssh_attach_restore_terminal; trap - EXIT HUP INT TERM; cmux_ssh_attach_lifecycle_end; exit \"$cmux_ssh_attach_signal_status\"; }", "trap 'cmux_ssh_attach_lifecycle_end' EXIT", "trap 'cmux_ssh_attach_signal_exit 129 HUP' HUP", "trap 'cmux_ssh_attach_signal_exit 130 INT' INT", From c8eb703ef42d258f544837723339341842a0a274 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:57:34 -0700 Subject: [PATCH 25/61] test: cover delayed SSH auth replacement completion --- .../SSHForegroundAuthenticationRetryPolicyTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 443a4f813500..cc4a93a66911 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -480,7 +480,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { # Exit immediately after publishing the replacement PID. The # cleanup handshake must discover it before this handler's parent # can disappear and reparent the replacement. - trap 'trap "" TERM; /usr/bin/perl "$CMUX_TEST_SETID_LAUNCHER" /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; exit 143' TERM + trap 'trap "" TERM; /bin/sleep 0.5; /usr/bin/perl "$CMUX_TEST_SETID_LAUNCHER" /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; exit 143' TERM : > "$CMUX_TEST_READY_MARKER" while :; do /bin/sleep 30; done """ From 38e22f3007b508d1d0d6bce91a5189a561c29f5b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:58:40 -0700 Subject: [PATCH 26/61] fix: publish SSH auth completion after handler exits --- .../SSHForegroundAuthenticationRetryPolicy.swift | 15 ++++++++------- ...ForegroundAuthenticationRetryPolicyTests.swift | 6 +++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 04e50ae0ae21..918f7bf676ea 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -307,12 +307,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_ssh_auth_snapshot" >> "$cmux_ssh_auth_dynamic_members" } - # The generated authentication wrapper writes one root PID after it - # has sent TERM to its command, then waits for the helper ACK before - # it waits for that command and exits. This keeps the wrapper alive - # while the helper takes the post-TERM process-group snapshot. A - # bounded read keeps plain fixtures and failed wrappers from - # blocking cleanup. + # The generated authentication wrapper publishes the nonce only after + # its TERM handler has waited for the command to finish. It then waits + # for the helper ACK before exiting. This gives the helper a + # happens-before edge for handler-created replacements while keeping + # the wrapper alive during the post-TERM process-table snapshot. A + # bounded read keeps plain fixtures and failed wrappers from blocking + # cleanup. cmux_ssh_auth_wait_for_term_event() { [ "$cmux_ssh_auth_wait_for_term_event_enabled" = 1 ] || return 0 [ -n "$cmux_ssh_auth_event_token" ] || return 0 @@ -811,9 +812,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { " trap - EXIT HUP INT TERM", " if [ -n \"${cmux_ssh_auth_command_pid:-}\" ]; then", " /bin/kill -\"$cmux_ssh_auth_capture_signal_name\" \"$cmux_ssh_auth_command_pid\" >/dev/null 2>&1 || true", - " cmux_ssh_auth_signal_completion", " wait \"$cmux_ssh_auth_command_pid\" 2>/dev/null || true", " cmux_ssh_auth_command_pid=", + " cmux_ssh_auth_signal_completion", " fi", " cmux_ssh_auth_capture_cleanup", " exit \"$cmux_ssh_auth_capture_signal_status\"", diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index cc4a93a66911..4224b53c38fc 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -477,9 +477,9 @@ struct SSHForegroundAuthenticationRetryPolicyTests { let policy = SSHForegroundAuthenticationRetryPolicy() let classifiedAuthentication = policy.classifyingTransientFailure( in: """ - # Exit immediately after publishing the replacement PID. The - # cleanup handshake must discover it before this handler's parent - # can disappear and reparent the replacement. + # Delay the replacement until the handler has started. The cleanup + # handshake must wait for its completion before the parent can + # disappear and reparent the replacement. trap 'trap "" TERM; /bin/sleep 0.5; /usr/bin/perl "$CMUX_TEST_SETID_LAUNCHER" /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; exit 143' TERM : > "$CMUX_TEST_READY_MARKER" while :; do /bin/sleep 30; done From e8ab7de20d81e92c02cde3e60bd631f31bb8fe2a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:13:28 -0700 Subject: [PATCH 27/61] test: anchor delayed SSH replacement to handler completion --- ...groundAuthenticationRetryPolicyTests.swift | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 4224b53c38fc..51a6ba491b62 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -453,8 +453,10 @@ struct SSHForegroundAuthenticationRetryPolicyTests { .appendingPathComponent("cmux-ssh-auth-replacement-\(UUID().uuidString)", isDirectory: true) let readyMarker = root.appendingPathComponent("ready") let replacementScript = root.appendingPathComponent("replacement.sh") + let delayedLauncher = root.appendingPathComponent("delayed-launcher.sh") let setIDLauncher = root.appendingPathComponent("setid-launcher.pl") let replacementPIDFile = root.appendingPathComponent("replacement.pid") + let handlerDone = root.appendingPathComponent("handler.done") let eventToken = UUID().uuidString.lowercased() try fileManager.createDirectory(at: root, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: root) } @@ -467,6 +469,15 @@ struct SSHForegroundAuthenticationRetryPolicyTests { """.write(to: replacementScript, atomically: true, encoding: .utf8) try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: replacementScript.path) try """ + #!/bin/sh + trap '' HUP INT TERM + /bin/sleep 0.5 + /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" & + printf '%s\\n' "$!" > "$CMUX_TEST_REPLACEMENT_PID" + : > "$CMUX_TEST_HANDLER_DONE" + """.write(to: delayedLauncher, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: delayedLauncher.path) + try """ #!/usr/bin/perl use POSIX qw(setsid); setsid() or exit 125; @@ -477,10 +488,10 @@ struct SSHForegroundAuthenticationRetryPolicyTests { let policy = SSHForegroundAuthenticationRetryPolicy() let classifiedAuthentication = policy.classifyingTransientFailure( in: """ - # Delay the replacement until the handler has started. The cleanup - # handshake must wait for its completion before the parent can - # disappear and reparent the replacement. - trap 'trap "" TERM; /bin/sleep 0.5; /usr/bin/perl "$CMUX_TEST_SETID_LAUNCHER" /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" /dev/null 2>&1 & printf "%s\\n" "$!" > "$CMUX_TEST_REPLACEMENT_PID"; exit 143' TERM + # Delay a detached replacement and wait for its completion marker. + # The cleanup handshake must wait for this handler before the + # parent can disappear and reparent the replacement. + trap 'trap "" TERM; /usr/bin/perl "$CMUX_TEST_SETID_LAUNCHER" /bin/sh "$CMUX_TEST_DELAYED_LAUNCHER" /dev/null 2>&1 & while [ ! -f "$CMUX_TEST_HANDLER_DONE" ]; do /bin/sleep 0.01; done; exit 143' TERM : > "$CMUX_TEST_READY_MARKER" while :; do /bin/sleep 30; done """ @@ -512,6 +523,8 @@ struct SSHForegroundAuthenticationRetryPolicyTests { process.environment = ProcessInfo.processInfo.environment.merging([ "CMUX_TEST_READY_MARKER": readyMarker.path, "CMUX_TEST_SETID_LAUNCHER": setIDLauncher.path, + "CMUX_TEST_DELAYED_LAUNCHER": delayedLauncher.path, + "CMUX_TEST_HANDLER_DONE": handlerDone.path, "CMUX_TEST_REPLACEMENT_SCRIPT": replacementScript.path, "CMUX_TEST_REPLACEMENT_PID": replacementPIDFile.path, ]) { _, override in override } From b0a560ef04f799a97d1fac6204526453d292c1f7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:15:23 -0700 Subject: [PATCH 28/61] test: make delayed SSH launcher reliably detached --- .../SSHForegroundAuthenticationRetryPolicyTests.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 51a6ba491b62..4663d65859f9 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -480,6 +480,12 @@ struct SSHForegroundAuthenticationRetryPolicyTests { try """ #!/usr/bin/perl use POSIX qw(setsid); + my $child = fork(); + defined $child or exit 124; + if ($child) { + waitpid($child, 0); + exit($? >> 8); + } setsid() or exit 125; exec @ARGV or exit 126; """.write(to: setIDLauncher, atomically: true, encoding: .utf8) From 248dc6151932c62cc8897e7c3d08f96b21ccd073 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:21:53 -0700 Subject: [PATCH 29/61] test: use direct detached SSH launcher --- .../SSHForegroundAuthenticationRetryPolicyTests.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 4663d65859f9..51a6ba491b62 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -480,12 +480,6 @@ struct SSHForegroundAuthenticationRetryPolicyTests { try """ #!/usr/bin/perl use POSIX qw(setsid); - my $child = fork(); - defined $child or exit 124; - if ($child) { - waitpid($child, 0); - exit($? >> 8); - } setsid() or exit 125; exec @ARGV or exit 126; """.write(to: setIDLauncher, atomically: true, encoding: .utf8) From 384291d4adf0f97cd4e6dbd12286ef0f8994f435 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:22:55 -0700 Subject: [PATCH 30/61] fix: complete SSH auth cleanup FIFO handshake --- ...SSHForegroundAuthenticationRetryPolicy.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 918f7bf676ea..e92a8eca7cd5 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -318,6 +318,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { [ "$cmux_ssh_auth_wait_for_term_event_enabled" = 1 ] || return 0 [ -n "$cmux_ssh_auth_event_token" ] || return 0 if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then return 0; fi + if [ ! -p "$cmux_ssh_auth_term_event_ack_fifo" ] || ! exec 10<> "$cmux_ssh_auth_term_event_ack_fifo"; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 cmux_ssh_auth_term_event_writer= # macOS /bin/sh accepts only an integer read timeout. One-second @@ -486,9 +487,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { [ -n "$cmux_ssh_auth_event_token" ] && /bin/mkdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null; then if /usr/bin/mkfifo "$cmux_ssh_auth_term_event_fifo" 2>/dev/null && \ - /usr/bin/mkfifo "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null && \ - exec 9<> "$cmux_ssh_auth_term_event_fifo" 2>/dev/null && \ - exec 10<> "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null; then + /usr/bin/mkfifo "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null; then cmux_ssh_auth_term_event_owned=1 else exec 9>&- 2>/dev/null || true @@ -788,10 +787,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "case \"$cmux_ssh_auth_event_token\" in ''|*[!A-Za-z0-9_-]*) cmux_ssh_auth_event_token= ;; esac", "cmux_ssh_auth_term_event_fifo=; cmux_ssh_auth_term_event_ack_fifo=; cmux_ssh_auth_marker_path=; cmux_ssh_auth_marker_owned=0", "if [ -n \"$cmux_ssh_auth_event_token\" ]; then cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/done\"; cmux_ssh_auth_term_event_ack_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/ack\"; cmux_ssh_auth_marker_path=\"${TMPDIR:-/tmp}/cmux-ssh-auth-marker.$cmux_ssh_auth_event_token\"; if ( set -C; : > \"$cmux_ssh_auth_marker_path\" ) 2>/dev/null; then if exec 7<> \"$cmux_ssh_auth_marker_path\" 2>/dev/null; then cmux_ssh_auth_marker_owned=1; else /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true; fi; fi; fi", - // Notify the cleanup helper after forwarding TERM, then wait for - // its ACK. The helper uses this pause to snapshot replacements - // before the command handler can orphan them. - "cmux_ssh_auth_signal_completion() { if [ -n \"$cmux_ssh_auth_event_token\" ] && [ -p \"$cmux_ssh_auth_term_event_fifo\" ]; then if exec 8<> \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null; then printf '%s\\n' \"$cmux_ssh_auth_event_token\" >&8 2>/dev/null || true; exec 8>&-; fi; if [ -p \"$cmux_ssh_auth_term_event_ack_fifo\" ] && exec 8<> \"$cmux_ssh_auth_term_event_ack_fifo\" 2>/dev/null; then cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack <&8 || true; exec 8>&-; fi; fi; }", + // Open both FIFO endpoints before waiting for the command. The + // helper can then enter a bounded read without blocking on FIFO + // setup, while the completion payload still has a happens-before + // edge after the TERM handler exits. + "cmux_ssh_auth_completion_fds_open=0", + "cmux_ssh_auth_prepare_signal_completion() { cmux_ssh_auth_completion_fds_open=0; if [ -n \"$cmux_ssh_auth_event_token\" ] && [ -p \"$cmux_ssh_auth_term_event_fifo\" ] && [ -p \"$cmux_ssh_auth_term_event_ack_fifo\" ] && exec 8<> \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null && exec 10<> \"$cmux_ssh_auth_term_event_ack_fifo\" 2>/dev/null; then cmux_ssh_auth_completion_fds_open=1; else exec 8>&- 2>/dev/null || true; exec 10>&- 2>/dev/null || true; fi; }", + "cmux_ssh_auth_signal_completion() { if [ \"$cmux_ssh_auth_completion_fds_open\" = 1 ]; then printf '%s\\n' \"$cmux_ssh_auth_event_token\" >&8 2>/dev/null || true; cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack <&10 || true; fi; exec 8>&- 2>/dev/null || true; exec 10>&- 2>/dev/null || true; cmux_ssh_auth_completion_fds_open=0; }", "cmux_ssh_auth_capture_cleanup() {", " if [ -n \"${cmux_ssh_auth_classifier_guard_fd:-}\" ]; then", " exec {cmux_ssh_auth_classifier_guard_fd}>&-", @@ -812,6 +814,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { " trap - EXIT HUP INT TERM", " if [ -n \"${cmux_ssh_auth_command_pid:-}\" ]; then", " /bin/kill -\"$cmux_ssh_auth_capture_signal_name\" \"$cmux_ssh_auth_command_pid\" >/dev/null 2>&1 || true", + " cmux_ssh_auth_prepare_signal_completion", " wait \"$cmux_ssh_auth_command_pid\" 2>/dev/null || true", " cmux_ssh_auth_command_pid=", " cmux_ssh_auth_signal_completion", From a2b98917c9d07b2ae7f06c0a71cc78558029f88a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:46:30 -0700 Subject: [PATCH 31/61] fix: anchor SSH completion wait to TERM delivery --- ...HForegroundAuthenticationRetryPolicy.swift | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index e92a8eca7cd5..bd172b68cc24 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -121,11 +121,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_cleanup_has_time() { [ "${SECONDS:-0}" -lt 2 ] } - cmux_ssh_auth_term_wait_has_time() { - # Keep at least one second for the force-freeze and KILL passes. - [ "${SECONDS:-0}" -lt 1 ] - } - umask 077 cmux_ssh_auth_state_dir=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/cmux-ssh-auth-tree.XXXXXX") || exit 0 cmux_ssh_auth_snapshot="$cmux_ssh_auth_state_dir/snapshot" @@ -321,19 +316,19 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { if [ ! -p "$cmux_ssh_auth_term_event_ack_fifo" ] || ! exec 10<> "$cmux_ssh_auth_term_event_ack_fifo"; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 cmux_ssh_auth_term_event_writer= - # macOS /bin/sh accepts only an integer read timeout. One-second - # waits are bounded by the same overall cleanup deadline. - while cmux_ssh_auth_term_wait_has_time; do - if IFS= read -r -t 1 cmux_ssh_auth_term_event_writer <&9; then - # The FIFO directory and payload both carry the random, - # per-attempt nonce. Process ownership is established by the - # marker FD journal, not by a PID that can be reused. - if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_event_token" ]; then - cmux_ssh_auth_term_event_received=1 - break - fi + # macOS /bin/sh accepts only an integer read timeout. The read is + # intentionally anchored to TERM delivery rather than the setup + # clock: process-table validation can consume the first second on + # a fork-starved runner, but the handler still needs one complete + # scheduling window to publish its completion event. + if IFS= read -r -t 1 cmux_ssh_auth_term_event_writer <&9; then + # The FIFO directory and payload both carry the random, + # per-attempt nonce. Process ownership is established by the + # marker FD journal, not by a PID that can be reused. + if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_event_token" ]; then + cmux_ssh_auth_term_event_received=1 fi - done + fi if [ "$cmux_ssh_auth_term_event_received" != 1 ]; then exec 9>&- fi From 5bb25e82c8c2b6a1278a6714bc63e0aa72b1d683 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:01:07 -0700 Subject: [PATCH 32/61] fix: keep SSH marker linked through cleanup --- .../SSHForegroundAuthenticationRetryPolicy.swift | 14 +++++++++++--- ...HForegroundAuthenticationRetryPolicyTests.swift | 11 ++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index bd172b68cc24..9bf540114c8a 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -447,6 +447,14 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_resume_file "$cmux_ssh_auth_pending" cmux_ssh_auth_resume_file "$cmux_ssh_auth_owned" fi + # Once the wrapper opens the per-attempt event FIFOs, marker + # cleanup is handed to this helper. The wrapper may time out while + # waiting for the ACK, but the marker must stay linked until every + # post-TERM discovery pass has finished so `lsof` can still prove + # ownership of a detached replacement. + if [ "$cmux_ssh_auth_term_event_owned" = 1 ] && [ -n "$cmux_ssh_auth_marker_path" ]; then + /bin/rm -f -- "$cmux_ssh_auth_marker_path" 2>/dev/null || true + fi if [ "$cmux_ssh_auth_term_event_owned" = 1 ]; then /bin/rm -f "$cmux_ssh_auth_term_event_fifo" "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null || true /bin/rmdir "$cmux_ssh_auth_term_event_dir" 2>/dev/null || true @@ -780,7 +788,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { // path from their unrelated `$$` values. "cmux_ssh_auth_event_token=\"${CMUX_SSH_AUTH_EVENT_TOKEN:-}\"", "case \"$cmux_ssh_auth_event_token\" in ''|*[!A-Za-z0-9_-]*) cmux_ssh_auth_event_token= ;; esac", - "cmux_ssh_auth_term_event_fifo=; cmux_ssh_auth_term_event_ack_fifo=; cmux_ssh_auth_marker_path=; cmux_ssh_auth_marker_owned=0", + "cmux_ssh_auth_term_event_fifo=; cmux_ssh_auth_term_event_ack_fifo=; cmux_ssh_auth_marker_path=; cmux_ssh_auth_marker_owned=0; cmux_ssh_auth_marker_cleanup_deferred=0", "if [ -n \"$cmux_ssh_auth_event_token\" ]; then cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/done\"; cmux_ssh_auth_term_event_ack_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/ack\"; cmux_ssh_auth_marker_path=\"${TMPDIR:-/tmp}/cmux-ssh-auth-marker.$cmux_ssh_auth_event_token\"; if ( set -C; : > \"$cmux_ssh_auth_marker_path\" ) 2>/dev/null; then if exec 7<> \"$cmux_ssh_auth_marker_path\" 2>/dev/null; then cmux_ssh_auth_marker_owned=1; else /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true; fi; fi; fi", // Open both FIFO endpoints before waiting for the command. The // helper can then enter a bounded read without blocking on FIFO @@ -788,7 +796,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { // edge after the TERM handler exits. "cmux_ssh_auth_completion_fds_open=0", "cmux_ssh_auth_prepare_signal_completion() { cmux_ssh_auth_completion_fds_open=0; if [ -n \"$cmux_ssh_auth_event_token\" ] && [ -p \"$cmux_ssh_auth_term_event_fifo\" ] && [ -p \"$cmux_ssh_auth_term_event_ack_fifo\" ] && exec 8<> \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null && exec 10<> \"$cmux_ssh_auth_term_event_ack_fifo\" 2>/dev/null; then cmux_ssh_auth_completion_fds_open=1; else exec 8>&- 2>/dev/null || true; exec 10>&- 2>/dev/null || true; fi; }", - "cmux_ssh_auth_signal_completion() { if [ \"$cmux_ssh_auth_completion_fds_open\" = 1 ]; then printf '%s\\n' \"$cmux_ssh_auth_event_token\" >&8 2>/dev/null || true; cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack <&10 || true; fi; exec 8>&- 2>/dev/null || true; exec 10>&- 2>/dev/null || true; cmux_ssh_auth_completion_fds_open=0; }", + "cmux_ssh_auth_signal_completion() { if [ \"$cmux_ssh_auth_completion_fds_open\" = 1 ]; then cmux_ssh_auth_marker_cleanup_deferred=1; printf '%s\\n' \"$cmux_ssh_auth_event_token\" >&8 2>/dev/null || true; cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack <&10 || true; fi; exec 8>&- 2>/dev/null || true; exec 10>&- 2>/dev/null || true; cmux_ssh_auth_completion_fds_open=0; }", "cmux_ssh_auth_capture_cleanup() {", " if [ -n \"${cmux_ssh_auth_classifier_guard_fd:-}\" ]; then", " exec {cmux_ssh_auth_classifier_guard_fd}>&-", @@ -800,7 +808,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { " wait \"$cmux_ssh_auth_capture_pid\" 2>/dev/null || true", " fi", " done", - " if [ \"${cmux_ssh_auth_marker_owned:-0}\" = 1 ]; then exec 7>&-; /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true; cmux_ssh_auth_marker_owned=0; fi", + " if [ \"${cmux_ssh_auth_marker_owned:-0}\" = 1 ]; then exec 7>&-; if [ \"${cmux_ssh_auth_marker_cleanup_deferred:-0}\" != 1 ]; then /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true; fi; cmux_ssh_auth_marker_owned=0; fi", " /bin/rm -f -- \"$cmux_ssh_auth_classifier_fifo\" \"$cmux_ssh_auth_capture_state\" 2>/dev/null || true", "}", "cmux_ssh_auth_capture_signal_exit() {", diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 51a6ba491b62..4443b64f7083 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -429,8 +429,10 @@ struct SSHForegroundAuthenticationRetryPolicyTests { defer { removeStandardErrorCapture(stderrCapture) } process.standardError = stderrCapture.handle + let startedAt = Date.now try process.run() - try waitForExit(process, stderrCapture: stderrCapture, timeout: 8) + try waitForExit(process, stderrCapture: stderrCapture, timeout: 10) + let elapsed = Date.now.timeIntervalSince(startedAt) let processIDs = try String(contentsOf: pidLog, encoding: .utf8) .split(separator: "\n") @@ -442,6 +444,13 @@ struct SSHForegroundAuthenticationRetryPolicyTests { #expect(process.terminationStatus == 0) #expect(processIDs.count == 25) + // The helper has one shared two-second discovery budget plus a bounded + // force pass. Keep a wall-clock assertion so a per-node timeout or a + // signal-handler hang cannot pass on eventual process termination. + #expect( + elapsed < 5, + "Foreground authentication cleanup took \(elapsed) seconds instead of one bounded deadline" + ) let processStates = processIDs.map(processLiveness) #expect(!processStates.contains(.unknown)) #expect(!processStates.contains(.live)) From bd59cad74221b499fbb6b2837f423a0f7e1302de Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:34:34 -0700 Subject: [PATCH 33/61] fix: validate SSH cleanup identities with kernel start times --- ...HForegroundAuthenticationRetryPolicy.swift | 197 ++++++++++++------ ...groundAuthenticationRetryPolicyTests.swift | 32 ++- 2 files changed, 141 insertions(+), 88 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 9bf540114c8a..54b62503c685 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -88,9 +88,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { /// Builds the shell helper that terminates a foreground-authentication process tree. /// - /// The helper takes a process-table snapshot, indexes parent/child edges in - /// one pass, and freezes the reachable tree with shell-builtin signals. Each - /// accepted record carries its PID, process group, and `ps lstart` identity. + /// The helper takes one kernel process-table snapshot, indexes parent/child + /// edges in one pass, and freezes the reachable tree with shell-builtin + /// signals. Each accepted record carries its PID, parent, process group, and + /// microsecond kernel start identity. /// A second snapshot must confirm the identity and stopped state before the /// helper sends `SIGKILL`. After `SIGTERM`, the helper waits for the /// per-attempt completion FIFO emitted by the authentication wrapper, then @@ -161,7 +162,49 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { : > "$cmux_ssh_auth_marker_holders" || exit 0 cmux_ssh_auth_take_snapshot() { - /bin/ps -axo pid=,ppid=,pgid=,state=,lstart= > "$cmux_ssh_auth_snapshot" 2>/dev/null + # `ps lstart` is only second-resolution. Read proc_bsdinfo directly + # so every snapshot carries the kernel birth timestamp instead of a + # value that can collide after rapid PID reuse. One Perl process + # walks the PID list, which keeps this path viable under fork + # pressure and avoids one child process per candidate. + /usr/bin/perl -e ' + use strict; + use warnings; + my $max_pid_count = 65536; + my $pid_buffer = "\0" x (4 * $max_pid_count); + my $pid_bytes = syscall(336, 1, 1, 0, 0, $pid_buffer, length($pid_buffer)); + exit 1 unless defined($pid_bytes) && $pid_bytes >= 0 && + $pid_bytes < length($pid_buffer) && ($pid_bytes % 4) == 0; + my %state = (1 => "I", 2 => "R", 3 => "S", 4 => "T", 5 => "Z"); + for (my $offset = 0; $offset < $pid_bytes; $offset += 4) { + my $pid = unpack("L<", substr($pid_buffer, $offset, 4)); + next unless $pid > 0; + my $info_buffer = "\0" x 184; + my $info_size = syscall(336, 2, $pid, 3, 0, $info_buffer, length($info_buffer)); + my ($group_offset, $seconds_offset, $microseconds_offset); + if ($info_size == 136) { + $group_offset = 100; + $seconds_offset = 120; + $microseconds_offset = 128; + } elsif ($info_size == 184) { + $group_offset = 148; + $seconds_offset = 168; + $microseconds_offset = 176; + } else { + next; + } + my $status = unpack("L<", substr($info_buffer, 4, 4)); + my $observed_pid = unpack("L<", substr($info_buffer, 12, 4)); + my $parent = unpack("L<", substr($info_buffer, 16, 4)); + my $group = unpack("L<", substr($info_buffer, $group_offset, 4)); + my $seconds = unpack("Q<", substr($info_buffer, $seconds_offset, 8)); + my $microseconds = unpack("Q<", substr($info_buffer, $microseconds_offset, 8)); + next unless $observed_pid == $pid && $group > 0 && $seconds > 0 && + $microseconds < 1_000_000; + next unless exists $state{$status}; + print "$pid $parent $group $state{$status} K $seconds $microseconds 0 0\n"; + } + ' > "$cmux_ssh_auth_snapshot" 2>/dev/null } cmux_ssh_auth_extract_tree() { @@ -238,10 +281,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # replacement reparented before the next process-table snapshot. The # classifier therefore keeps a per-attempt marker FD open. `lsof` # returns the exact processes that inherited that FD, including a - # detached replacement. Record their PID, PGID, and start identity, - # then follow only their current descendants. A random marker token - # and the inherited descriptor are the ownership proof; no numeric - # process-group reuse can authorize an unrelated process. + # detached replacement. Record their PID, parent, PGID, and kernel + # start identity, then follow only their current descendants. A random + # marker token and the inherited descriptor are the ownership proof; + # no numeric process-group reuse can authorize an unrelated process. cmux_ssh_auth_record_dynamic_members() { cmux_ssh_auth_take_snapshot || return 1 : > "$cmux_ssh_auth_marker_holders" || return 1 @@ -251,7 +294,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { fi /usr/bin/awk ' FILENAME == ARGV[1] { - cmux_original_identity[$2 SUBSEP $6] = 1 + # PPID is lineage metadata and can change when a TERM handler + # outlives its parent. PID, PGID, and the kernel birth token + # remain the stable identity fence. + cmux_original_identity[$2 SUBSEP $4 SUBSEP $6] = 1 next } FILENAME == ARGV[2] { @@ -293,8 +339,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { for (cmux_pid in cmux_lineage) { cmux_started_id = cmux_started[cmux_pid] if (cmux_state[cmux_pid] !~ /Z/ && - !((cmux_pid SUBSEP cmux_started_id) in cmux_original_identity)) { - print cmux_pid, cmux_group[cmux_pid], cmux_started_id + !((cmux_pid SUBSEP cmux_group[cmux_pid] SUBSEP cmux_started_id) in cmux_original_identity)) { + print cmux_pid, cmux_parent[cmux_pid], cmux_group[cmux_pid], cmux_started_id } } } @@ -341,47 +387,79 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { exec 9>&- } - # A successful STOP pins a process in place. If the identity check - # fails, resume every current process with the recorded PID that is - # either a different identity or no longer stopped. This undoes a - # stale-PID STOP without ever sending TERM or KILL to that process. + # A successful STOP pins a process in place. Resume only a journal + # identity that the kernel still reports with the same PID, process + # group, and microsecond birth timestamp. PPID is retained in the + # journal for lineage checks, but is not part of this rollback fence + # because a stopped child can be reparented while its owner exits. + # The validation and SIGCONT happen in one Perl process, so a failed + # process-table snapshot never turns a stale PID into an unverified + # signal. + cmux_ssh_auth_resume_kernel_journal() { + /usr/bin/perl -e ' + use strict; + use warnings; + my $journal_path = shift; + exit 0 unless defined $journal_path; + open my $journal, "<", $journal_path or exit 1; + + sub process_identity { + my ($pid) = @_; + my $buffer = "\0" x 184; + my $size = syscall(336, 2, $pid, 3, 0, $buffer, length($buffer)); + my ($group_offset, $seconds_offset, $microseconds_offset); + if ($size == 136) { + $group_offset = 100; + $seconds_offset = 120; + $microseconds_offset = 128; + } elsif ($size == 184) { + $group_offset = 148; + $seconds_offset = 168; + $microseconds_offset = 176; + } else { + return; + } + my $status = unpack("L<", substr($buffer, 4, 4)); + my $observed_pid = unpack("L<", substr($buffer, 12, 4)); + my $parent = unpack("L<", substr($buffer, 16, 4)); + my $group = unpack("L<", substr($buffer, $group_offset, 4)); + my $seconds = unpack("Q<", substr($buffer, $seconds_offset, 8)); + my $microseconds = unpack("Q<", substr($buffer, $microseconds_offset, 8)); + return ($observed_pid, $parent, $group, $status, $seconds, $microseconds); + } + + while (my $line = <$journal>) { + my @fields = grep { length } split(/\s+/, $line); + next unless @fields == 6; + my ($pid, $parent, $group, $started) = @fields[1, 2, 3, 5]; + next unless $pid =~ /\A[1-9][0-9]*\z/ && + $parent =~ /\A[0-9]+\z/ && $group =~ /\A[1-9][0-9]*\z/; + next unless $started =~ /\AK_([0-9]+)_([0-9]+)_0_0\z/; + my ($expected_seconds, $expected_microseconds) = ($1, $2); + next if $expected_microseconds >= 1_000_000; + my @identity = process_identity(0 + $pid); + next unless @identity == 6; + my ($observed_pid, $observed_parent, $observed_group, + $status, $seconds, $microseconds) = @identity; + next unless $observed_pid == $pid && $observed_group == $group && + $seconds == $expected_seconds && + $microseconds == $expected_microseconds && $status == 4; + kill("CONT", 0 + $pid); + } + ' "$1" >/dev/null 2>&1 || true + } + cmux_ssh_auth_resume_unconfirmed_stops() { cmux_ssh_auth_resume_path="$1" [ -s "$cmux_ssh_auth_resume_path" ] || return 0 - if ! cmux_ssh_auth_take_snapshot; then - # A missing snapshot cannot distinguish the journal PID from a - # reused PID. Leave the process stopped rather than signaling an - # unverified identity. - return 0 - fi - if ! cmux_ssh_auth_resume_pids=$( - /usr/bin/awk ' - FILENAME == ARGV[1] { - cmux_expected_pid[$2] = 1 - cmux_expected[$2 SUBSEP $4 SUBSEP $6] = 1 - next - } - NF >= 9 { - cmux_started = $5 "_" $6 "_" $7 "_" $8 "_" $9 - cmux_key = $1 SUBSEP $3 SUBSEP cmux_started - if (($1 in cmux_expected_pid) && - (!(cmux_key in cmux_expected) || $4 !~ /T/) && - $4 !~ /Z/) print $1 - } - ' "$cmux_ssh_auth_resume_path" "$cmux_ssh_auth_snapshot" - ); then - return 0 - fi - for cmux_ssh_auth_resume_pid in $cmux_ssh_auth_resume_pids; do - case "$cmux_ssh_auth_resume_pid" in ''|*[!0-9]*) continue ;; esac - kill -CONT "$cmux_ssh_auth_resume_pid" >/dev/null 2>&1 || true - done + cmux_ssh_auth_resume_kernel_journal "$cmux_ssh_auth_resume_path" } # Re-read the process table once immediately before each signal - # batch. A matching PID/PGID/start tuple is the only record emitted. - # STOP confirmation then pins that identity until TERM or KILL, so a - # PID reuse cannot turn a stale row into a destructive signal. + # batch. Every row carries PID, PPID, PGID, and a kernel-start token. + # The stable key uses PID, PGID, and that token because PPID changes + # during expected reparenting. Root validation and child edges still + # require the recorded PPID, so PID reuse cannot authorize a signal. cmux_ssh_auth_filter_current_records() { cmux_ssh_auth_filter_input="$1" cmux_ssh_auth_filter_output="$2" @@ -418,27 +496,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_resume_file() { cmux_ssh_auth_resume_path="$1" [ -s "$cmux_ssh_auth_resume_path" ] || return 0 - if ! cmux_ssh_auth_take_snapshot; then - return 0 - fi - if ! cmux_ssh_auth_resume_pids=$( - /usr/bin/awk ' - FILENAME == ARGV[1] { - cmux_expected[$2 SUBSEP $4 SUBSEP $6] = 1 - next - } - NF >= 9 { - cmux_started = $5 "_" $6 "_" $7 "_" $8 "_" $9 - if (($1 SUBSEP $3 SUBSEP cmux_started) in cmux_expected && $4 !~ /Z/) print $1 - } - ' "$cmux_ssh_auth_resume_path" "$cmux_ssh_auth_snapshot" - ); then - return 0 - fi - for cmux_ssh_auth_resume_pid in $cmux_ssh_auth_resume_pids; do - case "$cmux_ssh_auth_resume_pid" in ''|*[!0-9]*) continue ;; esac - kill -CONT "$cmux_ssh_auth_resume_pid" >/dev/null 2>&1 || true - done + cmux_ssh_auth_resume_kernel_journal "$cmux_ssh_auth_resume_path" } cmux_ssh_auth_cleanup() { @@ -601,8 +659,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { next } FILENAME == ARGV[2] { - if ($1 ~ /^[0-9]+$/ && $2 ~ /^[0-9]+$/ && $3 != "") { - cmux_dynamic_identity[$1 SUBSEP $2 SUBSEP $3] = 1 + if ($1 ~ /^[0-9]+$/ && $2 ~ /^[0-9]+$/ && + $3 ~ /^[0-9]+$/ && $4 != "") { + cmux_dynamic_identity[$1 SUBSEP $3 SUBSEP $4] = 1 } next } diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 4443b64f7083..010573b1e1aa 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -294,14 +294,14 @@ struct SSHForegroundAuthenticationRetryPolicyTests { )) defer { Darwin.kill(leafPID, SIGKILL) } let exitDeadline = Date.now.addingTimeInterval(1) - while processLiveness(leafPID) == .live, Date.now < exitDeadline { + while processLiveness(leafPID) == true, Date.now < exitDeadline { Thread.sleep(forTimeInterval: 0.01) } #expect(process.terminationStatus == 0) #expect(try String(contentsOf: signalLog, encoding: .utf8) == "term\n") - #expect(processLiveness(leafPID) == .terminated) - #expect(processLiveness(leafPID) != .unknown) + #expect(processLiveness(leafPID) == false) + #expect(processLiveness(leafPID) != nil) } @Test func refusesAuthenticationRootWithMismatchedKnownParent() throws { @@ -438,7 +438,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { .split(separator: "\n") .compactMap { Int32($0) } let exitDeadline = Date.now.addingTimeInterval(1) - while processIDs.contains(where: { processLiveness($0) == .live }), Date.now < exitDeadline { + while processIDs.contains(where: { processLiveness($0) == true }), Date.now < exitDeadline { Thread.sleep(forTimeInterval: 0.01) } @@ -452,8 +452,8 @@ struct SSHForegroundAuthenticationRetryPolicyTests { "Foreground authentication cleanup took \(elapsed) seconds instead of one bounded deadline" ) let processStates = processIDs.map(processLiveness) - #expect(!processStates.contains(.unknown)) - #expect(!processStates.contains(.live)) + #expect(!processStates.contains(where: { $0 == nil })) + #expect(!processStates.contains(where: { $0 == true })) } @Test func terminatesReplacementSpawnedByAuthenticationTermHandler() throws { @@ -552,13 +552,13 @@ struct SSHForegroundAuthenticationRetryPolicyTests { )) defer { Darwin.kill(replacementPID, SIGKILL) } let exitDeadline = Date.now.addingTimeInterval(1) - while processLiveness(replacementPID) == .live, Date.now < exitDeadline { + while processLiveness(replacementPID) == true, Date.now < exitDeadline { Thread.sleep(forTimeInterval: 0.01) } #expect(process.terminationStatus == 0) - #expect(processLiveness(replacementPID) == .terminated) - #expect(processLiveness(replacementPID) != .unknown) + #expect(processLiveness(replacementPID) == false) + #expect(processLiveness(replacementPID) != nil) } @Test func restoresTerminalModesWhenTerminatingForegroundAuthenticationTree() throws { @@ -790,13 +790,7 @@ struct SSHForegroundAuthenticationRetryPolicyTests { } } - private enum ProcessLiveness: Equatable { - case live - case terminated - case unknown - } - - private func processLiveness(_ processID: Int32) -> ProcessLiveness { + private func processLiveness(_ processID: Int32) -> Bool? { // kill(pid, 0) also succeeds for zombies. The cleanup helper treats a // zombie as terminated, so inspect process state before reporting a // survivor. An unexpected proc_pidinfo result is unknown, not proof of @@ -811,11 +805,11 @@ struct SSHForegroundAuthenticationRetryPolicyTests { Int32(expectedSize) ) if Int(size) == expectedSize { - return info.pbi_status == UInt32(SZOMB) ? .terminated : .live + return info.pbi_status == UInt32(SZOMB) ? false : true } if size == 0 && errno == ESRCH { - return .terminated + return false } - return .unknown + return nil } } From ae4b050a6a1daf08faea7fad157cb4612cd2be52 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:34:21 -0700 Subject: [PATCH 34/61] fix: fence SSH cleanup signals by process identity --- ...HForegroundAuthenticationRetryPolicy.swift | 182 +++++++++++------- ...groundAuthenticationRetryPolicyTests.swift | 5 +- 2 files changed, 120 insertions(+), 67 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 54b62503c685..0e31615f4424 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -362,19 +362,22 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { if [ ! -p "$cmux_ssh_auth_term_event_ack_fifo" ] || ! exec 10<> "$cmux_ssh_auth_term_event_ack_fifo"; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 cmux_ssh_auth_term_event_writer= - # macOS /bin/sh accepts only an integer read timeout. The read is - # intentionally anchored to TERM delivery rather than the setup - # clock: process-table validation can consume the first second on - # a fork-starved runner, but the handler still needs one complete - # scheduling window to publish its completion event. - if IFS= read -r -t 1 cmux_ssh_auth_term_event_writer <&9; then - # The FIFO directory and payload both carry the random, - # per-attempt nonce. Process ownership is established by the - # marker FD journal, not by a PID that can be reused. - if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_event_token" ]; then - cmux_ssh_auth_term_event_received=1 + # macOS /bin/sh accepts only an integer read timeout. Retry the + # blocking read through the remaining cleanup deadline so a TERM + # handler that is delayed by scheduler pressure can still publish + # its replacement marker. The FIFO stays open across retries. + cmux_ssh_auth_term_event_wait_start=${SECONDS:-0} + while [ "$cmux_ssh_auth_term_event_received" != 1 ] && + [ "$((${SECONDS:-0} - cmux_ssh_auth_term_event_wait_start))" -lt 2 ]; do + if IFS= read -r -t 1 cmux_ssh_auth_term_event_writer <&9; then + # The FIFO directory and payload both carry the random, + # per-attempt nonce. Process ownership is established by the + # marker FD journal, not by a PID that can be reused. + if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_event_token" ]; then + cmux_ssh_auth_term_event_received=1 + fi fi - fi + done if [ "$cmux_ssh_auth_term_event_received" != 1 ]; then exec 9>&- fi @@ -395,13 +398,29 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # The validation and SIGCONT happen in one Perl process, so a failed # process-table snapshot never turns a stale PID into an unverified # signal. - cmux_ssh_auth_resume_kernel_journal() { + # Validate and signal an identity batch in one Perl process. The + # shell/awk snapshot is only a candidate list. Every operation gets + # a fresh proc_bsdinfo read immediately before the signal, and STOP + # candidates are checked again after the signal. A PID that was + # replaced can therefore be briefly stopped, but it is resumed before + # it can enter the ownership journal. TERM and KILL are sent only to a + # confirmed stopped identity. A stopped process cannot exit and reuse + # its PID, which closes the destructive KILL window without relying on + # a numeric PID after it has been released. + cmux_ssh_auth_signal_verified_batch() { + cmux_ssh_auth_signal_name="$1" + cmux_ssh_auth_signal_input="$2" + cmux_ssh_auth_signal_output="${3:-/dev/null}" /usr/bin/perl -e ' use strict; use warnings; - my $journal_path = shift; - exit 0 unless defined $journal_path; - open my $journal, "<", $journal_path or exit 1; + + my ($signal, $input_path, $output_path) = @ARGV; + exit 2 unless defined $signal && defined $input_path && defined $output_path; + exit 2 unless $signal eq "STOP" || $signal eq "TERM" || + $signal eq "CONT" || $signal eq "KILL"; + open my $input, "<", $input_path or exit 1; + open my $output, ">", $output_path or exit 1; sub process_identity { my ($pid) = @_; @@ -428,25 +447,84 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { return ($observed_pid, $parent, $group, $status, $seconds, $microseconds); } - while (my $line = <$journal>) { + sub same_identity { + my ($identity, $pid, $group, $seconds, $microseconds) = @_; + return 0 unless defined $identity && @$identity == 6; + return $identity->[0] == $pid && $identity->[2] == $group && + $identity->[4] == $seconds && $identity->[5] == $microseconds; + } + + while (my $line = <$input>) { + chomp $line; my @fields = grep { length } split(/\s+/, $line); next unless @fields == 6; - my ($pid, $parent, $group, $started) = @fields[1, 2, 3, 5]; - next unless $pid =~ /\A[1-9][0-9]*\z/ && - $parent =~ /\A[0-9]+\z/ && $group =~ /\A[1-9][0-9]*\z/; + my ($depth, $pid, $parent, $group, $original_state, $started) = @fields; + next unless $depth =~ /\A[0-9]+\z/ && + $pid =~ /\A[1-9][0-9]*\z/ && + $parent =~ /\A[0-9]+\z/ && + $group =~ /\A[1-9][0-9]*\z/; next unless $started =~ /\AK_([0-9]+)_([0-9]+)_0_0\z/; my ($expected_seconds, $expected_microseconds) = ($1, $2); next if $expected_microseconds >= 1_000_000; - my @identity = process_identity(0 + $pid); - next unless @identity == 6; - my ($observed_pid, $observed_parent, $observed_group, - $status, $seconds, $microseconds) = @identity; - next unless $observed_pid == $pid && $observed_group == $group && - $seconds == $expected_seconds && - $microseconds == $expected_microseconds && $status == 4; - kill("CONT", 0 + $pid); + my @before = process_identity(0 + $pid); + next unless same_identity(\@before, 0 + $pid, 0 + $group, + $expected_seconds, $expected_microseconds); + next if $before[3] == 5; + + if ($signal eq "STOP") { + # Never claim a process that was already stopped. It may be + # owned by an unrelated debugger, and resuming it would be + # an observable side effect of failed cleanup. + next if $before[3] == 4; + next unless kill("STOP", 0 + $pid); + my @after = process_identity(0 + $pid); + if (same_identity(\@after, 0 + $pid, 0 + $group, + $expected_seconds, $expected_microseconds) && $after[3] == 4) { + print {$output} $line, "\n"; + } else { + # If the PID changed, only resume the process that this + # helper just stopped. A stopped process cannot be reused + # between this identity check and CONT. + my @rollback = process_identity(0 + $pid); + if (@rollback == 6 && $rollback[3] == 4 && + !same_identity(\@rollback, 0 + $pid, 0 + $group, + $expected_seconds, $expected_microseconds)) { + kill("CONT", 0 + $pid); + } + } + next; + } + + # Rollback never resumes a process that was already stopped + # before this helper acquired it. + next if $signal eq "CONT" && $original_state eq "T"; + next unless $before[3] == 4; + if ($signal eq "TERM") { + if (!kill("TERM", 0 + $pid)) { + my @current = process_identity(0 + $pid); + kill("CONT", 0 + $pid) + if same_identity(\@current, 0 + $pid, 0 + $group, + $expected_seconds, $expected_microseconds) && $current[3] == 4; + next; + } + my @after = process_identity(0 + $pid); + kill("CONT", 0 + $pid) + if same_identity(\@after, 0 + $pid, 0 + $group, + $expected_seconds, $expected_microseconds) && $after[3] == 4; + } elsif ($signal eq "CONT") { + kill("CONT", 0 + $pid); + } elsif ($signal eq "KILL") { + # The identity was confirmed stopped immediately above, so + # the kernel cannot recycle this PID before KILL is queued. + kill("KILL", 0 + $pid); + } } - ' "$1" >/dev/null 2>&1 || true + ' "$cmux_ssh_auth_signal_name" "$cmux_ssh_auth_signal_input" \ + "$cmux_ssh_auth_signal_output" >/dev/null 2>&1 + } + + cmux_ssh_auth_resume_kernel_journal() { + cmux_ssh_auth_signal_verified_batch CONT "$1" /dev/null || true } cmux_ssh_auth_resume_unconfirmed_stops() { @@ -578,17 +656,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { break fi : > "$cmux_ssh_auth_pending" - while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do - case "$cmux_pid:$cmux_parent:$cmux_group:$cmux_started" in - *[!0-9A-Za-z_:]*|:*|*:) continue ;; - esac - # Only successful STOP calls enter the pending ownership journal. - if kill -STOP "$cmux_pid" >/dev/null 2>&1; then - printf '%s %s %s %s %s %s\n' \ - "$cmux_depth" "$cmux_pid" "$cmux_parent" "$cmux_group" "$cmux_state" "$cmux_started" \ - >> "$cmux_ssh_auth_pending" || exit 0 - fi - done < "$cmux_ssh_auth_stop_candidates" + # Only STOP operations that pass the in-process identity fence + # enter the pending ownership journal. + cmux_ssh_auth_signal_verified_batch STOP \ + "$cmux_ssh_auth_stop_candidates" "$cmux_ssh_auth_pending" || exit 0 cmux_ssh_auth_append_pending || exit 0 if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_tree; then @@ -633,11 +704,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { } } ' "$cmux_ssh_auth_term_candidates" > "$cmux_ssh_auth_term" || exit 0 - while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do - case "$cmux_pid" in ''|*[!0-9]*) continue ;; esac - kill -TERM "$cmux_pid" >/dev/null 2>&1 || true - kill -CONT "$cmux_pid" >/dev/null 2>&1 || true - done < "$cmux_ssh_auth_term" + cmux_ssh_auth_signal_verified_batch TERM "$cmux_ssh_auth_term" /dev/null || true # The wrapper sends its event immediately after forwarding TERM and # waits for our ACK. Snapshot before ACK so a replacement is still # attached to the live wrapper even when its direct parent exits. @@ -733,16 +800,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { break fi : > "$cmux_ssh_auth_pending" - while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do - case "$cmux_pid:$cmux_parent:$cmux_group:$cmux_started" in - *[!0-9A-Za-z_:]*|:*|*:) continue ;; - esac - if kill -STOP "$cmux_pid" >/dev/null 2>&1; then - printf '%s %s %s %s %s %s\n' \ - "$cmux_depth" "$cmux_pid" "$cmux_parent" "$cmux_group" "$cmux_state" "$cmux_started" \ - >> "$cmux_ssh_auth_pending" || exit 0 - fi - done < "$cmux_ssh_auth_stop_candidates" + cmux_ssh_auth_signal_verified_batch STOP \ + "$cmux_ssh_auth_stop_candidates" "$cmux_ssh_auth_pending" || exit 0 cmux_ssh_auth_append_pending || exit 0 if ! cmux_ssh_auth_take_snapshot || ! cmux_ssh_auth_extract_owned; then cmux_ssh_auth_resume_unconfirmed_stops "$cmux_ssh_auth_owned" @@ -770,17 +829,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { exit 0 fi cmux_ssh_auth_kill_failed=0 - while IFS=' ' read -r cmux_depth cmux_pid cmux_parent cmux_group cmux_state cmux_started; do - case "$cmux_pid" in ''|*[!0-9]*) continue ;; esac - if ! kill -KILL "$cmux_pid" >/dev/null 2>&1; then - # A process can exit between the confirming snapshot and this - # builtin call. Retry once, then leave the EXIT rollback armed - # if the PID still refuses the signal. - if ! kill -KILL "$cmux_pid" >/dev/null 2>&1; then - cmux_ssh_auth_kill_failed=1 - fi - fi - done < "$cmux_ssh_auth_kill_candidates" + cmux_ssh_auth_signal_verified_batch KILL \ + "$cmux_ssh_auth_kill_candidates" /dev/null || cmux_ssh_auth_kill_failed=1 if [ "$cmux_ssh_auth_kill_failed" = 0 ]; then cmux_ssh_auth_cleanup_complete=1 fi diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 010573b1e1aa..19a07e3026db 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -480,7 +480,10 @@ struct SSHForegroundAuthenticationRetryPolicyTests { try """ #!/bin/sh trap '' HUP INT TERM - /bin/sleep 0.5 + # Keep the replacement behind the first one-second FIFO read. The + # helper must retry the completion event through its bounded deadline + # before releasing the wrapper and discovering the detached child. + /bin/sleep 1.2 /bin/sh "$CMUX_TEST_REPLACEMENT_SCRIPT" & printf '%s\\n' "$!" > "$CMUX_TEST_REPLACEMENT_PID" : > "$CMUX_TEST_HANDLER_DONE" From 83e09fdfa4beebc98a58d55bcbd955cc6d0eae2f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:48:31 -0700 Subject: [PATCH 35/61] fix: use audit-token signals for SSH cleanup --- ...HForegroundAuthenticationRetryPolicy.swift | 242 +++++++++--------- 1 file changed, 119 insertions(+), 123 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 0e31615f4424..41933ea4357a 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -390,136 +390,132 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { exec 9>&- } - # A successful STOP pins a process in place. Resume only a journal - # identity that the kernel still reports with the same PID, process - # group, and microsecond birth timestamp. PPID is retained in the - # journal for lineage checks, but is not part of this rollback fence - # because a stopped child can be reparented while its owner exits. - # The validation and SIGCONT happen in one Perl process, so a failed - # process-table snapshot never turns a stale PID into an unverified - # signal. - # Validate and signal an identity batch in one Perl process. The - # shell/awk snapshot is only a candidate list. Every operation gets - # a fresh proc_bsdinfo read immediately before the signal, and STOP - # candidates are checked again after the signal. A PID that was - # replaced can therefore be briefly stopped, but it is resumed before - # it can enter the ownership journal. TERM and KILL are sent only to a - # confirmed stopped identity. A stopped process cannot exit and reuse - # its PID, which closes the destructive KILL window without relying on - # a numeric PID after it has been released. + # Validate and signal an identity batch with macOS's audit-token + # process API. The shell/awk snapshot is only a candidate list. The + # audit token carries the kernel PID version, so the kernel rejects a + # signal after PID reuse instead of applying it to the new process. + # STOP candidates are checked again after the signal and only a + # confirmed stop enters the ownership journal. TERM and KILL are sent + # only to a confirmed stopped identity. A stopped process cannot exit + # and reuse its PID, which also closes the destructive KILL window. cmux_ssh_auth_signal_verified_batch() { cmux_ssh_auth_signal_name="$1" cmux_ssh_auth_signal_input="$2" cmux_ssh_auth_signal_output="${3:-/dev/null}" - /usr/bin/perl -e ' - use strict; - use warnings; - - my ($signal, $input_path, $output_path) = @ARGV; - exit 2 unless defined $signal && defined $input_path && defined $output_path; - exit 2 unless $signal eq "STOP" || $signal eq "TERM" || - $signal eq "CONT" || $signal eq "KILL"; - open my $input, "<", $input_path or exit 1; - open my $output, ">", $output_path or exit 1; - - sub process_identity { - my ($pid) = @_; - my $buffer = "\0" x 184; - my $size = syscall(336, 2, $pid, 3, 0, $buffer, length($buffer)); - my ($group_offset, $seconds_offset, $microseconds_offset); - if ($size == 136) { - $group_offset = 100; - $seconds_offset = 120; - $microseconds_offset = 128; - } elsif ($size == 184) { - $group_offset = 148; - $seconds_offset = 168; - $microseconds_offset = 176; - } else { - return; - } - my $status = unpack("L<", substr($buffer, 4, 4)); - my $observed_pid = unpack("L<", substr($buffer, 12, 4)); - my $parent = unpack("L<", substr($buffer, 16, 4)); - my $group = unpack("L<", substr($buffer, $group_offset, 4)); - my $seconds = unpack("Q<", substr($buffer, $seconds_offset, 8)); - my $microseconds = unpack("Q<", substr($buffer, $microseconds_offset, 8)); - return ($observed_pid, $parent, $group, $status, $seconds, $microseconds); - } - - sub same_identity { - my ($identity, $pid, $group, $seconds, $microseconds) = @_; - return 0 unless defined $identity && @$identity == 6; - return $identity->[0] == $pid && $identity->[2] == $group && - $identity->[4] == $seconds && $identity->[5] == $microseconds; - } - - while (my $line = <$input>) { - chomp $line; - my @fields = grep { length } split(/\s+/, $line); - next unless @fields == 6; - my ($depth, $pid, $parent, $group, $original_state, $started) = @fields; - next unless $depth =~ /\A[0-9]+\z/ && - $pid =~ /\A[1-9][0-9]*\z/ && - $parent =~ /\A[0-9]+\z/ && - $group =~ /\A[1-9][0-9]*\z/; - next unless $started =~ /\AK_([0-9]+)_([0-9]+)_0_0\z/; - my ($expected_seconds, $expected_microseconds) = ($1, $2); - next if $expected_microseconds >= 1_000_000; - my @before = process_identity(0 + $pid); - next unless same_identity(\@before, 0 + $pid, 0 + $group, - $expected_seconds, $expected_microseconds); - next if $before[3] == 5; - - if ($signal eq "STOP") { - # Never claim a process that was already stopped. It may be - # owned by an unrelated debugger, and resuming it would be - # an observable side effect of failed cleanup. - next if $before[3] == 4; - next unless kill("STOP", 0 + $pid); - my @after = process_identity(0 + $pid); - if (same_identity(\@after, 0 + $pid, 0 + $group, - $expected_seconds, $expected_microseconds) && $after[3] == 4) { - print {$output} $line, "\n"; - } else { - # If the PID changed, only resume the process that this - # helper just stopped. A stopped process cannot be reused - # between this identity check and CONT. - my @rollback = process_identity(0 + $pid); - if (@rollback == 6 && $rollback[3] == 4 && - !same_identity(\@rollback, 0 + $pid, 0 + $group, - $expected_seconds, $expected_microseconds)) { - kill("CONT", 0 + $pid); - } - } - next; - } + /usr/bin/ruby -rfiddle -rfiddle/import -e ' + signal_name, input_path, output_path = ARGV + signals = { "STOP" => 17, "TERM" => 15, "CONT" => 19, "KILL" => 9 } + exit 2 unless signals.key?(signal_name) && input_path && output_path + + module CmuxLibproc + extend Fiddle::Importer + dlload "/usr/lib/libproc.dylib" + extern "int proc_pidinfo(int, int, unsigned long long, void*, int)" + extern "int proc_signal_with_audittoken(void*, int)" + end + + bsd_with_unique_id_flavor = 18 + process_info_size = 192 + uint32 = ->(bytes, offset) { bytes.byteslice(offset, 4).unpack1("L<") } + uint64 = ->(bytes, offset) { bytes.byteslice(offset, 8).unpack1("Q<") } + process_identity = lambda do |pid| + buffer = Fiddle::Pointer.malloc(process_info_size) + written = CmuxLibproc.proc_pidinfo( + Integer(pid), bsd_with_unique_id_flavor, 0, buffer, process_info_size + ) + next nil unless written == process_info_size + bytes = buffer.to_s(process_info_size) + [ + uint32.call(bytes, 12), # pbi_pid + uint32.call(bytes, 16), # pbi_ppid + uint32.call(bytes, 100), # pbi_pgid + uint32.call(bytes, 4), # pbi_status + uint64.call(bytes, 120), # pbi_start_tvsec + uint64.call(bytes, 128), # pbi_start_tvusec + uint32.call(bytes, 168), # proc_uniqueidentifierinfo.id_version + ] + rescue ArgumentError, Fiddle::DLError, NoMethodError, RangeError, TypeError + nil + end + same_identity = lambda do |identity, pid, group, seconds, microseconds| + identity && identity.length == 7 && identity[0] == pid && + identity[2] == group && identity[4] == seconds && identity[5] == microseconds + end + signal_exact = lambda do |identity, signal_number| + token = Fiddle::Pointer.malloc(32) + token[0, 32] = ([0xffffffff] * 8).pack("L<*") + token[20, 4] = [identity[0]].pack("L<") + token[28, 4] = [identity[6]].pack("L<") + CmuxLibproc.proc_signal_with_audittoken(token, signal_number) == 0 + end + + begin + input = File.open(input_path, "r") + output = File.open(output_path, "w") + input.each_line do |line| + fields = line.split + next unless fields.length == 6 + depth, pid_text, parent_text, group_text, original_state, started = fields + next unless depth.match?(/\A[0-9]+\z/) && pid_text.match?(/\A[1-9][0-9]*\z/) && + parent_text.match?(/\A[0-9]+\z/) && group_text.match?(/\A[1-9][0-9]*\z/) + match = started.match(/\AK_([0-9]+)_([0-9]+)_0_0\z/) + next unless match + expected_seconds = Integer(match[1]) + expected_microseconds = Integer(match[2]) + next if expected_microseconds >= 1_000_000 + pid = Integer(pid_text) + group = Integer(group_text) + before = process_identity.call(pid) + next unless same_identity.call(before, pid, group, expected_seconds, expected_microseconds) + next if before[3] == 5 + + if signal_name == "STOP" + # Do not claim a process that was already stopped by another + # owner. Resuming it would be an observable side effect. + next if before[3] == 4 + next unless signal_exact.call(before, signals[signal_name]) + after = process_identity.call(pid) + if same_identity.call(after, pid, group, expected_seconds, expected_microseconds) && + after[3] == 4 + output.puts(line.chomp) + else + # A PID-reuse candidate may be briefly stopped. Resume it + # only while the same replacement identity is still stopped. + replacement = process_identity.call(pid) + if replacement && replacement[3] == 4 && + !same_identity.call(replacement, pid, group, expected_seconds, expected_microseconds) + signal_exact.call(replacement, signals["CONT"]) + end + end + next + end # Rollback never resumes a process that was already stopped # before this helper acquired it. - next if $signal eq "CONT" && $original_state eq "T"; - next unless $before[3] == 4; - if ($signal eq "TERM") { - if (!kill("TERM", 0 + $pid)) { - my @current = process_identity(0 + $pid); - kill("CONT", 0 + $pid) - if same_identity(\@current, 0 + $pid, 0 + $group, - $expected_seconds, $expected_microseconds) && $current[3] == 4; - next; - } - my @after = process_identity(0 + $pid); - kill("CONT", 0 + $pid) - if same_identity(\@after, 0 + $pid, 0 + $group, - $expected_seconds, $expected_microseconds) && $after[3] == 4; - } elsif ($signal eq "CONT") { - kill("CONT", 0 + $pid); - } elsif ($signal eq "KILL") { - # The identity was confirmed stopped immediately above, so - # the kernel cannot recycle this PID before KILL is queued. - kill("KILL", 0 + $pid); - } - } - ' "$cmux_ssh_auth_signal_name" "$cmux_ssh_auth_signal_input" \ + next if signal_name == "CONT" && original_state == "T" + next unless before[3] == 4 + if signal_name == "TERM" + unless signal_exact.call(before, signals[signal_name]) + current = process_identity.call(pid) + signal_exact.call(current, signals["CONT"]) if same_identity.call( + current, pid, group, expected_seconds, expected_microseconds + ) && current[3] == 4 + next + end + after = process_identity.call(pid) + signal_exact.call(after, signals["CONT"]) if same_identity.call( + after, pid, group, expected_seconds, expected_microseconds + ) && after[3] == 4 + else + signal_exact.call(before, signals[signal_name]) + end + end + ensure + input&.close + output&.close + end + end + ' "$cmux_ssh_auth_signal_name" "$cmux_ssh_auth_signal_input" \ "$cmux_ssh_auth_signal_output" >/dev/null 2>&1 } From 0531b8ca0ad09d397864996c766d4d9031cbd2dd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:29:43 -0700 Subject: [PATCH 36/61] fix: keep SSH auth cleanup wrapper alive for TERM handlers --- ...HForegroundAuthenticationRetryPolicy.swift | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 41933ea4357a..9483d5819383 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -362,13 +362,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { if [ ! -p "$cmux_ssh_auth_term_event_ack_fifo" ] || ! exec 10<> "$cmux_ssh_auth_term_event_ack_fifo"; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 cmux_ssh_auth_term_event_writer= - # macOS /bin/sh accepts only an integer read timeout. Retry the - # blocking read through the remaining cleanup deadline so a TERM - # handler that is delayed by scheduler pressure can still publish - # its replacement marker. The FIFO stays open across retries. + # macOS /bin/sh accepts only an integer read timeout. Start this + # bounded grace after TERM so scheduler pressure can delay the + # handler without blocking cleanup forever. The FIFO stays open + # across retries. cmux_ssh_auth_term_event_wait_start=${SECONDS:-0} while [ "$cmux_ssh_auth_term_event_received" != 1 ] && - [ "$((${SECONDS:-0} - cmux_ssh_auth_term_event_wait_start))" -lt 2 ]; do + [ "$((${SECONDS:-0} - cmux_ssh_auth_term_event_wait_start))" -lt 5 ]; do if IFS= read -r -t 1 cmux_ssh_auth_term_event_writer <&9; then # The FIFO directory and payload both carry the random, # per-attempt nonce. Process ownership is established by the @@ -514,7 +514,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { input&.close output&.close end - end ' "$cmux_ssh_auth_signal_name" "$cmux_ssh_auth_signal_input" \ "$cmux_ssh_auth_signal_output" >/dev/null 2>&1 } @@ -858,7 +857,27 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { /// - Parameter command: Foreground authentication command to execute under zsh. /// - Returns: A zsh command suitable for embedding in a startup script. public func classifyingTransientFailure(in command: String) -> String { - let nestedCommand = "/usr/bin/env LC_ALL=C LANG=C /bin/zsh -fc \(shellQuote(command))" + // `script` closes descriptors inherited from its launcher. Reopen the + // per-attempt marker in the shell it starts, before replacing that + // shell with the authentication command, so detached descendants keep + // the ownership capability. + let markerBootstrap = """ + if [ -n "${CMUX_SSH_AUTH_EVENT_TOKEN:-}" ]; then + case "$CMUX_SSH_AUTH_EVENT_TOKEN" in + ''|*[!A-Za-z0-9_-]*) ;; + *) + cmux_ssh_auth_marker_path="${TMPDIR:-/tmp}/cmux-ssh-auth-marker.$CMUX_SSH_AUTH_EVENT_TOKEN" + if [ -f "$cmux_ssh_auth_marker_path" ]; then + # zsh's managed descriptors are close-on-exec. Use a fixed + # descriptor so the marker survives the nested env/zsh exec. + exec 7<> "$cmux_ssh_auth_marker_path" 2>/dev/null || true + fi + ;; + esac + fi + exec /usr/bin/env LC_ALL=C LANG=C /bin/zsh -fc \(shellQuote(command)) + """ + let nestedCommand = "/bin/zsh -fc \(shellQuote(markerBootstrap))" let classifierProgram = """ { cmux_ssh_auth_line = tolower(cmux_ssh_auth_overlap $0) @@ -899,9 +918,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { // helper can then enter a bounded read without blocking on FIFO // setup, while the completion payload still has a happens-before // edge after the TERM handler exits. + "cmux_ssh_auth_completion_event_fd=", + "cmux_ssh_auth_completion_ack_fd=", "cmux_ssh_auth_completion_fds_open=0", - "cmux_ssh_auth_prepare_signal_completion() { cmux_ssh_auth_completion_fds_open=0; if [ -n \"$cmux_ssh_auth_event_token\" ] && [ -p \"$cmux_ssh_auth_term_event_fifo\" ] && [ -p \"$cmux_ssh_auth_term_event_ack_fifo\" ] && exec 8<> \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null && exec 10<> \"$cmux_ssh_auth_term_event_ack_fifo\" 2>/dev/null; then cmux_ssh_auth_completion_fds_open=1; else exec 8>&- 2>/dev/null || true; exec 10>&- 2>/dev/null || true; fi; }", - "cmux_ssh_auth_signal_completion() { if [ \"$cmux_ssh_auth_completion_fds_open\" = 1 ]; then cmux_ssh_auth_marker_cleanup_deferred=1; printf '%s\\n' \"$cmux_ssh_auth_event_token\" >&8 2>/dev/null || true; cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack <&10 || true; fi; exec 8>&- 2>/dev/null || true; exec 10>&- 2>/dev/null || true; cmux_ssh_auth_completion_fds_open=0; }", + "cmux_ssh_auth_prepare_signal_completion() { cmux_ssh_auth_completion_fds_open=0; if [ -n \"$cmux_ssh_auth_event_token\" ] && [ -p \"$cmux_ssh_auth_term_event_fifo\" ] && [ -p \"$cmux_ssh_auth_term_event_ack_fifo\" ] && exec {cmux_ssh_auth_completion_event_fd}<> \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null && exec {cmux_ssh_auth_completion_ack_fd}<> \"$cmux_ssh_auth_term_event_ack_fifo\" 2>/dev/null; then cmux_ssh_auth_completion_fds_open=1; else exec {cmux_ssh_auth_completion_event_fd}>&- 2>/dev/null || true; exec {cmux_ssh_auth_completion_ack_fd}>&- 2>/dev/null || true; cmux_ssh_auth_completion_event_fd=; cmux_ssh_auth_completion_ack_fd=; fi; }", + "cmux_ssh_auth_signal_completion() { if [ \"$cmux_ssh_auth_completion_fds_open\" = 1 ]; then cmux_ssh_auth_marker_cleanup_deferred=1; printf '%s\\n' \"$cmux_ssh_auth_event_token\" >&$cmux_ssh_auth_completion_event_fd 2>/dev/null || true; cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack <&$cmux_ssh_auth_completion_ack_fd || true; fi; if [ -n \"${cmux_ssh_auth_completion_event_fd:-}\" ]; then exec {cmux_ssh_auth_completion_event_fd}>&- 2>/dev/null || true; fi; if [ -n \"${cmux_ssh_auth_completion_ack_fd:-}\" ]; then exec {cmux_ssh_auth_completion_ack_fd}>&- 2>/dev/null || true; fi; cmux_ssh_auth_completion_event_fd=; cmux_ssh_auth_completion_ack_fd=; cmux_ssh_auth_completion_fds_open=0; }", "cmux_ssh_auth_capture_cleanup() {", " if [ -n \"${cmux_ssh_auth_classifier_guard_fd:-}\" ]; then", " exec {cmux_ssh_auth_classifier_guard_fd}>&-", @@ -938,7 +959,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "exec {cmux_ssh_auth_classifier_guard_fd}<> \"$cmux_ssh_auth_classifier_fifo\" || exit 255", "( exec {cmux_ssh_auth_classifier_guard_fd}>&-; zmodload zsh/system || exit 255; exec {cmux_ssh_auth_classifier_fd}< \"$cmux_ssh_auth_classifier_fifo\" || exit 255; while sysread -i \"$cmux_ssh_auth_classifier_fd\" -s 4096 cmux_ssh_auth_classifier_chunk; do print -r -- \"$cmux_ssh_auth_classifier_chunk\"; done; exec {cmux_ssh_auth_classifier_fd}<&- ) | ( exec {cmux_ssh_auth_classifier_guard_fd}>&-; LC_ALL=C /usr/bin/awk -v cmux_ssh_auth_classification=\"$cmux_ssh_auth_capture_state\" -v cmux_ssh_auth_transient_pattern=\(shellQuote(transientFailurePattern)) -v cmux_ssh_auth_permanent_pattern=\(shellQuote(permanentFailurePattern)) \(shellQuote(classifierProgram)) ) &", "cmux_ssh_auth_classifier_pid=$!", - "( exec {cmux_ssh_auth_classifier_guard_fd}>&-; exec /usr/bin/script -q -F \"$cmux_ssh_auth_classifier_fifo\" \(nestedCommand) <&0 >&2 ) &", + // In event mode the helper must keep `script` alive until the + // nested TERM handler publishes its completion marker. Ignore + // only the helper's HUP/TERM signals in that mode; ordinary + // wrappers retain their normal signal behavior. + "( exec {cmux_ssh_auth_classifier_guard_fd}>&-; if [ -n \"$cmux_ssh_auth_event_token\" ]; then trap '' HUP TERM; fi; exec /usr/bin/script -q -F \"$cmux_ssh_auth_classifier_fifo\" \(nestedCommand) <&0 >&2 ) &", "cmux_ssh_auth_command_pid=$!", "wait \"$cmux_ssh_auth_command_pid\"", "cmux_ssh_auth_capture_status=$?", From 1f82bb5448e38672c7d0a0a72e58622c6e412717 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:03:32 -0700 Subject: [PATCH 37/61] fix: keep SSH cleanup portable across remote hosts --- ...HForegroundAuthenticationRetryPolicy.swift | 213 ++++++++++++++++-- 1 file changed, 200 insertions(+), 13 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 9483d5819383..5550e285dcd5 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -91,7 +91,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { /// The helper takes one kernel process-table snapshot, indexes parent/child /// edges in one pass, and freezes the reachable tree with shell-builtin /// signals. Each accepted record carries its PID, parent, process group, and - /// microsecond kernel start identity. + /// a host process-start identity. Darwin uses the microsecond kernel start + /// token; other Unix hosts use their POSIX `ps` start tuple. /// A second snapshot must confirm the identity and stopped state before the /// helper sends `SIGKILL`. After `SIGTERM`, the helper waits for the /// per-attempt completion FIFO emitted by the authentication wrapper, then @@ -156,18 +157,25 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { fi cmux_ssh_auth_term_event_owned=0 cmux_ssh_auth_term_event_received=0 + cmux_ssh_auth_signal_backend=portable + cmux_ssh_auth_snapshot_format= + cmux_ssh_auth_cleanup_needs_root_abort=0 + if [ "$(uname -s 2>/dev/null || true)" = Darwin ]; then + cmux_ssh_auth_signal_backend=darwin + fi : > "$cmux_ssh_auth_owned" || exit 0 : > "$cmux_ssh_auth_pending" || exit 0 : > "$cmux_ssh_auth_dynamic_members" || exit 0 : > "$cmux_ssh_auth_marker_holders" || exit 0 cmux_ssh_auth_take_snapshot() { - # `ps lstart` is only second-resolution. Read proc_bsdinfo directly - # so every snapshot carries the kernel birth timestamp instead of a - # value that can collide after rapid PID reuse. One Perl process - # walks the PID list, which keeps this path viable under fork - # pressure and avoids one child process per candidate. - /usr/bin/perl -e ' + if [ "$cmux_ssh_auth_signal_backend" = darwin ]; then + # `ps lstart` is only second-resolution. Read proc_bsdinfo + # directly so every snapshot carries the kernel birth timestamp + # instead of a value that can collide after rapid PID reuse. One + # Perl process walks the PID list, which keeps this path viable + # under fork pressure and avoids one child process per candidate. + if /usr/bin/perl -e ' use strict; use warnings; my $max_pid_count = 65536; @@ -204,7 +212,52 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { next unless exists $state{$status}; print "$pid $parent $group $state{$status} K $seconds $microseconds 0 0\n"; } - ' > "$cmux_ssh_auth_snapshot" 2>/dev/null + ' > "$cmux_ssh_auth_snapshot" 2>/dev/null && + [ -s "$cmux_ssh_auth_snapshot" ]; then + if [ -n "$cmux_ssh_auth_snapshot_format" ] && + [ "$cmux_ssh_auth_snapshot_format" != darwin ]; then + cmux_ssh_auth_cleanup_needs_root_abort=1 + return 1 + fi + cmux_ssh_auth_snapshot_format=darwin + return 0 + fi + if [ -n "$cmux_ssh_auth_snapshot_format" ]; then + # Do not mix kernel identities with second-resolution ps rows. + # A later Darwin probe failure is handled by the root abort + # path instead of silently dropping the ownership journal. + cmux_ssh_auth_cleanup_needs_root_abort=1 + return 1 + fi + # Older or non-Darwin SSH targets can still provide a POSIX ps + # view. Switch the signal backend with the snapshot format so a + # failed Darwin probe cannot leave the caller waiting forever. + cmux_ssh_auth_signal_backend=portable + fi + cmux_ssh_auth_ps_command=$(command -v ps 2>/dev/null || true) + if [ -z "$cmux_ssh_auth_ps_command" ]; then + cmux_ssh_auth_cleanup_needs_root_abort=1 + return 1 + fi + "$cmux_ssh_auth_ps_command" -axo pid=,ppid=,pgid=,state=,lstart= 2>/dev/null | + /usr/bin/awk ' + NF >= 9 { + # Portable records use the complete ps start tuple as an + # opaque identity. Darwin records use K__. + print $1, $2, $3, $4, "P_" $5 "_" $6 "_" $7 "_" $8 "_" $9, 0, 0, 0, 0 + } + ' > "$cmux_ssh_auth_snapshot" + if [ ! -s "$cmux_ssh_auth_snapshot" ]; then + cmux_ssh_auth_cleanup_needs_root_abort=1 + return 1 + fi + if [ -n "$cmux_ssh_auth_snapshot_format" ] && + [ "$cmux_ssh_auth_snapshot_format" != portable ]; then + cmux_ssh_auth_cleanup_needs_root_abort=1 + return 1 + fi + cmux_ssh_auth_snapshot_format=portable + return 0 } cmux_ssh_auth_extract_tree() { @@ -362,6 +415,33 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { if [ ! -p "$cmux_ssh_auth_term_event_ack_fifo" ] || ! exec 10<> "$cmux_ssh_auth_term_event_ack_fifo"; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 cmux_ssh_auth_term_event_writer= + if [ "$cmux_ssh_auth_signal_backend" != darwin ]; then + # POSIX sh has no timed-read primitive. Use one bounded select + # call when Perl is available, and fail open when it is not. + cmux_ssh_auth_perl_command=$(command -v perl 2>/dev/null || true) + if [ -n "$cmux_ssh_auth_perl_command" ]; then + cmux_ssh_auth_term_event_writer=$( + "$cmux_ssh_auth_perl_command" -MIO::Select -e ' + use strict; + use warnings; + use Fcntl qw(O_RDWR O_NONBLOCK); + my ($path, $timeout) = @ARGV; + sysopen(my $fifo, $path, O_RDWR | O_NONBLOCK) or exit 1; + my $select = IO::Select->new($fifo); + if ($select->can_read($timeout)) { + my $line = <$fifo>; + print $line if defined $line; + } + ' "$cmux_ssh_auth_term_event_fifo" 5 2>/dev/null || true + ) + fi + if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_event_token" ]; then + cmux_ssh_auth_term_event_received=1 + else + exec 9>&- + fi + return 0 + fi # macOS /bin/sh accepts only an integer read timeout. Start this # bounded grace after TERM so scheduler pressure can delay the # handler without blocking cleanup forever. The FIFO stays open @@ -390,18 +470,90 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { exec 9>&- } - # Validate and signal an identity batch with macOS's audit-token - # process API. The shell/awk snapshot is only a candidate list. The - # audit token carries the kernel PID version, so the kernel rejects a - # signal after PID reuse instead of applying it to the new process. + # Validate and signal an identity batch. On Darwin, the shell/awk + # snapshot is fenced again with the kernel audit token, which carries + # the PID version. Other Unix hosts use a fresh POSIX ps snapshot and + # the shell's validated signal builtin instead of loading libproc. # STOP candidates are checked again after the signal and only a # confirmed stop enters the ownership journal. TERM and KILL are sent # only to a confirmed stopped identity. A stopped process cannot exit # and reuse its PID, which also closes the destructive KILL window. + cmux_ssh_auth_signal_portable_batch() { + cmux_ssh_auth_portable_signal_name="$1" + cmux_ssh_auth_portable_signal_input="$2" + cmux_ssh_auth_portable_signal_output="${3:-/dev/null}" + case "$cmux_ssh_auth_portable_signal_name" in + STOP) cmux_ssh_auth_portable_require_stopped=0 ;; + TERM|CONT|KILL) cmux_ssh_auth_portable_require_stopped=1 ;; + *) return 2 ;; + esac + cmux_ssh_auth_portable_candidates="$cmux_ssh_auth_state_dir/portable-candidates" + : > "$cmux_ssh_auth_portable_candidates" || return 1 + if ! cmux_ssh_auth_filter_current_records \ + "$cmux_ssh_auth_portable_signal_input" \ + "$cmux_ssh_auth_portable_candidates" \ + "$cmux_ssh_auth_portable_require_stopped"; then + return 1 + fi + cmux_ssh_auth_portable_failed=0 + while IFS=' ' read -r cmux_ssh_auth_portable_depth \ + cmux_ssh_auth_portable_pid cmux_ssh_auth_portable_parent \ + cmux_ssh_auth_portable_group cmux_ssh_auth_portable_state \ + cmux_ssh_auth_portable_started; do + case "$cmux_ssh_auth_portable_pid" in + ''|*[!0-9]*) continue ;; + esac + case "$cmux_ssh_auth_portable_signal_name" in + STOP) + case "$cmux_ssh_auth_portable_state" in *T*) continue ;; esac + if kill -STOP "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1; then + printf '%s\n' \ + "$cmux_ssh_auth_portable_depth $cmux_ssh_auth_portable_pid $cmux_ssh_auth_portable_parent $cmux_ssh_auth_portable_group $cmux_ssh_auth_portable_state $cmux_ssh_auth_portable_started" \ + >> "$cmux_ssh_auth_portable_signal_output" || cmux_ssh_auth_portable_failed=1 + else + cmux_ssh_auth_portable_failed=1 + fi + ;; + TERM) + if kill -TERM "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1; then + kill -CONT "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1 || true + else + # A failed TERM must not strand a process that this helper + # stopped. The caller will retry the identity-checked CONT. + kill -CONT "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1 || true + cmux_ssh_auth_portable_failed=1 + fi + ;; + CONT) + case "$cmux_ssh_auth_portable_state" in *T*) + kill -CONT "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1 || cmux_ssh_auth_portable_failed=1 + ;; + esac + ;; + KILL) + kill -KILL "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1 || cmux_ssh_auth_portable_failed=1 + ;; + esac + done < "$cmux_ssh_auth_portable_candidates" + /bin/rm -f "$cmux_ssh_auth_portable_candidates" 2>/dev/null || true + [ "$cmux_ssh_auth_portable_failed" = 0 ] + } + cmux_ssh_auth_signal_verified_batch() { cmux_ssh_auth_signal_name="$1" cmux_ssh_auth_signal_input="$2" cmux_ssh_auth_signal_output="${3:-/dev/null}" + if [ "$cmux_ssh_auth_signal_backend" != darwin ]; then + cmux_ssh_auth_signal_portable_batch \ + "$cmux_ssh_auth_signal_name" \ + "$cmux_ssh_auth_signal_input" \ + "$cmux_ssh_auth_signal_output" + cmux_ssh_auth_signal_status=$? + if [ "$cmux_ssh_auth_signal_status" -ne 0 ]; then + cmux_ssh_auth_cleanup_needs_root_abort=1 + fi + return "$cmux_ssh_auth_signal_status" + fi /usr/bin/ruby -rfiddle -rfiddle/import -e ' signal_name, input_path, output_path = ARGV signals = { "STOP" => 17, "TERM" => 15, "CONT" => 19, "KILL" => 9 } @@ -452,6 +604,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { begin input = File.open(input_path, "r") output = File.open(output_path, "w") + signal_failed = false input.each_line do |line| fields = line.split next unless fields.length == 6 @@ -507,15 +660,29 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { after, pid, group, expected_seconds, expected_microseconds ) && after[3] == 4 else - signal_exact.call(before, signals[signal_name]) + unless signal_exact.call(before, signals[signal_name]) + # A process may exit between validation and KILL. Treat + # that race as success, but report a live matching stop so + # the caller resumes it instead of declaring cleanup done. + current = process_identity.call(pid) + signal_failed = true if same_identity.call( + current, pid, group, expected_seconds, expected_microseconds + ) && current[3] == 4 + end end end + exit 1 if signal_failed ensure input&.close output&.close end ' "$cmux_ssh_auth_signal_name" "$cmux_ssh_auth_signal_input" \ "$cmux_ssh_auth_signal_output" >/dev/null 2>&1 + cmux_ssh_auth_signal_status=$? + if [ "$cmux_ssh_auth_signal_status" -ne 0 ]; then + cmux_ssh_auth_cleanup_needs_root_abort=1 + fi + return "$cmux_ssh_auth_signal_status" } cmux_ssh_auth_resume_kernel_journal() { @@ -572,11 +739,29 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_resume_kernel_journal "$cmux_ssh_auth_resume_path" } + cmux_ssh_auth_force_root_termination() { + case "$cmux_ssh_auth_tree_root_pid" in + ''|*[!0-9]*) return 0 ;; + esac + # The root PID is still the caller's unreaped child. If identity + # cleanup cannot produce a trustworthy snapshot, terminate that + # child so the caller's wait cannot hang indefinitely. Descendant + # cleanup remains best effort on hosts without a richer process API. + if kill -0 "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1; then + kill -CONT "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1 || true + kill -TERM "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1 || true + kill -KILL "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1 || true + fi + } + cmux_ssh_auth_cleanup() { trap - EXIT HUP INT TERM if [ "$cmux_ssh_auth_cleanup_complete" != 1 ]; then cmux_ssh_auth_resume_file "$cmux_ssh_auth_pending" cmux_ssh_auth_resume_file "$cmux_ssh_auth_owned" + if [ "$cmux_ssh_auth_cleanup_needs_root_abort" = 1 ]; then + cmux_ssh_auth_force_root_termination + fi fi # Once the wrapper opens the per-attempt event FIFOs, marker # cleanup is handed to this helper. The wrapper may time out while @@ -828,6 +1013,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_ssh_auth_kill_candidates" /dev/null || cmux_ssh_auth_kill_failed=1 if [ "$cmux_ssh_auth_kill_failed" = 0 ]; then cmux_ssh_auth_cleanup_complete=1 + else + cmux_ssh_auth_cleanup_needs_root_abort=1 fi ) """# From a04a41854f748632a828da76d9b5d581904778dd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:21:30 -0700 Subject: [PATCH 38/61] fix: use exact Linux process start identities --- ...HForegroundAuthenticationRetryPolicy.swift | 61 +++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 5550e285dcd5..87b4b1458ff5 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -92,7 +92,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { /// edges in one pass, and freezes the reachable tree with shell-builtin /// signals. Each accepted record carries its PID, parent, process group, and /// a host process-start identity. Darwin uses the microsecond kernel start - /// token; other Unix hosts use their POSIX `ps` start tuple. + /// token; Linux uses the monotonic `/proc` start counter. /// A second snapshot must confirm the identity and stopped state before the /// helper sends `SIGKILL`. After `SIGTERM`, the helper waits for the /// per-attempt completion FIFO emitted by the authentication wrapper, then @@ -229,25 +229,17 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_cleanup_needs_root_abort=1 return 1 fi - # Older or non-Darwin SSH targets can still provide a POSIX ps - # view. Switch the signal backend with the snapshot format so a - # failed Darwin probe cannot leave the caller waiting forever. + # Linux exposes an exact monotonic process-start counter through + # procfs. Do not substitute ps lstart here: its one-second value + # is not an identity fence under PID reuse. cmux_ssh_auth_signal_backend=portable fi - cmux_ssh_auth_ps_command=$(command -v ps 2>/dev/null || true) - if [ -z "$cmux_ssh_auth_ps_command" ]; then + if [ ! -r /proc/1/stat ]; then cmux_ssh_auth_cleanup_needs_root_abort=1 return 1 fi - "$cmux_ssh_auth_ps_command" -axo pid=,ppid=,pgid=,state=,lstart= 2>/dev/null | - /usr/bin/awk ' - NF >= 9 { - # Portable records use the complete ps start tuple as an - # opaque identity. Darwin records use K__. - print $1, $2, $3, $4, "P_" $5 "_" $6 "_" $7 "_" $8 "_" $9, 0, 0, 0, 0 - } - ' > "$cmux_ssh_auth_snapshot" - if [ ! -s "$cmux_ssh_auth_snapshot" ]; then + cmux_ssh_auth_perl_command=$(command -v perl 2>/dev/null || true) + if [ -z "$cmux_ssh_auth_perl_command" ]; then cmux_ssh_auth_cleanup_needs_root_abort=1 return 1 fi @@ -256,6 +248,39 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_cleanup_needs_root_abort=1 return 1 fi + if ! "$cmux_ssh_auth_perl_command" -e ' + use strict; + use warnings; + opendir my $proc, "/proc" or exit 1; + for my $entry (readdir $proc) { + next unless $entry =~ /\A[1-9][0-9]*\z/; + my $path = "/proc/$entry/stat"; + open my $input, "<", $path or next; + my $line = <$input>; + close $input; + next unless defined $line; + # The command name may contain spaces and closing parens. The + # fields after the final close-parenthesis have the stable + # procfs layout. + next unless $line =~ /\A([1-9][0-9]*) \(.*\) (.*)\z/; + my $pid = $1; + my @fields = split /\s+/, $2; + next unless @fields >= 20; + my ($state, $parent, $group, $start) = @fields[0, 1, 2, 19]; + next unless $state =~ /\A[A-Za-z]\z/ && + $parent =~ /\A[0-9]+\z/ && $group =~ /\A[1-9][0-9]*\z/ && + $start =~ /\A[1-9][0-9]*\z/; + $state = uc $state; + print "$pid $parent $group $state P_${start}_0_0_0_0\n"; + } + ' > "$cmux_ssh_auth_snapshot" 2>/dev/null; then + cmux_ssh_auth_cleanup_needs_root_abort=1 + return 1 + fi + if [ ! -s "$cmux_ssh_auth_snapshot" ]; then + cmux_ssh_auth_cleanup_needs_root_abort=1 + return 1 + fi cmux_ssh_auth_snapshot_format=portable return 0 } @@ -472,8 +497,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # Validate and signal an identity batch. On Darwin, the shell/awk # snapshot is fenced again with the kernel audit token, which carries - # the PID version. Other Unix hosts use a fresh POSIX ps snapshot and - # the shell's validated signal builtin instead of loading libproc. + # the PID version. Linux uses a fresh procfs snapshot and the shell's + # validated signal builtin instead of loading libproc. # STOP candidates are checked again after the signal and only a # confirmed stop enters the ownership journal. TERM and KILL are sent # only to a confirmed stopped identity. A stopped process cannot exit @@ -567,6 +592,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { end bsd_with_unique_id_flavor = 18 + # This flavor returns proc_bsdinfo (136 bytes) followed by + # proc_uniqidentifierinfo (56 bytes), for a 192-byte record. process_info_size = 192 uint32 = ->(bytes, offset) { bytes.byteslice(offset, 4).unpack1("L<") } uint64 = ->(bytes, offset) { bytes.byteslice(offset, 8).unpack1("Q<") } From 2e47f93e4bbb1c51f5f56a3b12ee8b935c3cad2c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:33:37 -0700 Subject: [PATCH 39/61] fix: close SSH cleanup failure paths safely --- ...HForegroundAuthenticationRetryPolicy.swift | 91 ++++++++++++------- 1 file changed, 56 insertions(+), 35 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 87b4b1458ff5..34d301d161f5 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -115,16 +115,49 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { *) cmux_ssh_auth_wait_for_term_event_enabled=0 ;; esac + # Install a small failure trap before any filesystem operation. The + # full cleanup trap needs the state files below, but setup failures + # must still terminate the known root so its caller cannot wait + # forever. + cmux_ssh_auth_setup_failed=0 + cmux_ssh_auth_cleanup_complete=0 + cmux_ssh_auth_state_dir= + cmux_ssh_auth_force_root_termination() { + case "$cmux_ssh_auth_tree_root_pid" in + ''|*[!0-9]*) return 0 ;; + esac + if kill -0 "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1; then + kill -CONT "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1 || true + kill -TERM "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1 || true + kill -KILL "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1 || true + fi + } + cmux_ssh_auth_early_cleanup() { + trap - EXIT HUP INT TERM + if [ "$cmux_ssh_auth_setup_failed" = 1 ]; then + cmux_ssh_auth_force_root_termination + if [ -n "$cmux_ssh_auth_state_dir" ]; then + /bin/rm -f -- "$cmux_ssh_auth_state_dir"/* 2>/dev/null || true + /bin/rmdir "$cmux_ssh_auth_state_dir" 2>/dev/null || true + fi + fi + } + trap 'cmux_ssh_auth_early_cleanup' EXIT + cmux_ssh_auth_setup_abort() { + cmux_ssh_auth_setup_failed=1 + exit 0 + } + # SECONDS is provided by the /bin/sh used by the generated launchers # and avoids one fork per deadline check. The pass limits below are a # second bound if an older shell does not update it. SECONDS=0 - cmux_ssh_auth_cleanup_complete=0 cmux_ssh_auth_cleanup_has_time() { [ "${SECONDS:-0}" -lt 2 ] } - umask 077 - cmux_ssh_auth_state_dir=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/cmux-ssh-auth-tree.XXXXXX") || exit 0 + umask 077 || cmux_ssh_auth_setup_abort + cmux_ssh_auth_state_dir=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/cmux-ssh-auth-tree.XXXXXX") || cmux_ssh_auth_setup_abort + [ -n "$cmux_ssh_auth_state_dir" ] || cmux_ssh_auth_setup_abort cmux_ssh_auth_snapshot="$cmux_ssh_auth_state_dir/snapshot" cmux_ssh_auth_members="$cmux_ssh_auth_state_dir/members" cmux_ssh_auth_pending="$cmux_ssh_auth_state_dir/pending" @@ -163,10 +196,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { if [ "$(uname -s 2>/dev/null || true)" = Darwin ]; then cmux_ssh_auth_signal_backend=darwin fi - : > "$cmux_ssh_auth_owned" || exit 0 - : > "$cmux_ssh_auth_pending" || exit 0 - : > "$cmux_ssh_auth_dynamic_members" || exit 0 - : > "$cmux_ssh_auth_marker_holders" || exit 0 + : > "$cmux_ssh_auth_owned" || cmux_ssh_auth_setup_abort + : > "$cmux_ssh_auth_pending" || cmux_ssh_auth_setup_abort + : > "$cmux_ssh_auth_dynamic_members" || cmux_ssh_auth_setup_abort + : > "$cmux_ssh_auth_marker_holders" || cmux_ssh_auth_setup_abort cmux_ssh_auth_take_snapshot() { if [ "$cmux_ssh_auth_signal_backend" = darwin ]; then @@ -654,25 +687,28 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # owner. Resuming it would be an observable side effect. next if before[3] == 4 next unless signal_exact.call(before, signals[signal_name]) - after = process_identity.call(pid) - if same_identity.call(after, pid, group, expected_seconds, expected_microseconds) && - after[3] == 4 - output.puts(line.chomp) - else - # A PID-reuse candidate may be briefly stopped. Resume it - # only while the same replacement identity is still stopped. - replacement = process_identity.call(pid) - if replacement && replacement[3] == 4 && - !same_identity.call(replacement, pid, group, expected_seconds, expected_microseconds) - signal_exact.call(replacement, signals["CONT"]) - end - end + # SIGSTOP delivery can be asynchronous to proc_pidinfo. The + # audit-token call already verified this exact identity, so + # journal every successful STOP immediately. A later pass + # confirms the stopped state before TERM or KILL; rollback + # sends CONT even if that state check still sees the process + # running, which prevents a delayed STOP from stranding it. + output.puts(line.chomp) next end # Rollback never resumes a process that was already stopped # before this helper acquired it. next if signal_name == "CONT" && original_state == "T" + if signal_name == "CONT" + unless signal_exact.call(before, signals[signal_name]) + current = process_identity.call(pid) + signal_failed = true if same_identity.call( + current, pid, group, expected_seconds, expected_microseconds + ) && current[3] == 4 + end + next + end next unless before[3] == 4 if signal_name == "TERM" unless signal_exact.call(before, signals[signal_name]) @@ -766,21 +802,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_resume_kernel_journal "$cmux_ssh_auth_resume_path" } - cmux_ssh_auth_force_root_termination() { - case "$cmux_ssh_auth_tree_root_pid" in - ''|*[!0-9]*) return 0 ;; - esac - # The root PID is still the caller's unreaped child. If identity - # cleanup cannot produce a trustworthy snapshot, terminate that - # child so the caller's wait cannot hang indefinitely. Descendant - # cleanup remains best effort on hosts without a richer process API. - if kill -0 "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1; then - kill -CONT "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1 || true - kill -TERM "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1 || true - kill -KILL "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1 || true - fi - } - cmux_ssh_auth_cleanup() { trap - EXIT HUP INT TERM if [ "$cmux_ssh_auth_cleanup_complete" != 1 ]; then From 5238ff310d27ede4cbb77ad1fe010742632ea6b8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 07:53:46 -0700 Subject: [PATCH 40/61] fix: fence SSH cleanup fallback termination --- ...HForegroundAuthenticationRetryPolicy.swift | 201 ++++++++++++++++-- 1 file changed, 182 insertions(+), 19 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 34d301d161f5..8e2ca853bcea 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -122,15 +122,173 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_setup_failed=0 cmux_ssh_auth_cleanup_complete=0 cmux_ssh_auth_state_dir= + cmux_ssh_auth_platform="$(uname -s 2>/dev/null || true)" + cmux_ssh_auth_root_termination_identity= + cmux_ssh_auth_perl_command= + cmux_ssh_auth_capture_root_termination_identity() { + case "$cmux_ssh_auth_platform" in + Darwin) + cmux_ssh_auth_root_termination_identity=$( + /usr/bin/ruby -rfiddle -rfiddle/import -e ' + module CmuxLibproc + extend Fiddle::Importer + dlload "/usr/lib/libproc.dylib" + extern "int proc_pidinfo(int, int, unsigned long long, void*, int)" + end + pid = Integer(ARGV[0]) + expected_parent = Integer(ARGV[1]) + size = 192 + buffer = Fiddle::Pointer.malloc(size) + written = CmuxLibproc.proc_pidinfo(pid, 18, 0, buffer, size) + exit 1 unless written == size + bytes = buffer.to_s(size) + uint32 = ->(offset) { bytes.byteslice(offset, 4).unpack1("L<") } + uint64 = ->(offset) { bytes.byteslice(offset, 8).unpack1("Q<") } + observed_pid = uint32.call(12) + parent = uint32.call(16) + group = uint32.call(100) + status = uint32.call(4) + seconds = uint64.call(120) + microseconds = uint64.call(128) + version = uint32.call(168) + exit 1 unless observed_pid == pid && parent == expected_parent && + group > 0 && status != 5 && seconds > 0 && + microseconds < 1_000_000 && version > 0 + puts "D:#{pid}:#{parent}:#{group}:#{seconds}:#{microseconds}:#{version}" + ' "$cmux_ssh_auth_tree_root_pid" "$cmux_ssh_auth_tree_root_parent" 2>/dev/null + ) || cmux_ssh_auth_root_termination_identity= + ;; + *) + cmux_ssh_auth_perl_command=$(command -v perl 2>/dev/null || true) + if [ -n "$cmux_ssh_auth_perl_command" ] && + [ -r "/proc/$cmux_ssh_auth_tree_root_pid/stat" ]; then + cmux_ssh_auth_root_termination_identity=$( + "$cmux_ssh_auth_perl_command" -e ' + use strict; + use warnings; + my ($pid, $expected_parent) = @ARGV; + open my $input, "<", "/proc/$pid/stat" or exit 1; + my $line = <$input>; + close $input; + chomp $line if defined $line; + exit 1 unless defined $line && + $line =~ /\A([1-9][0-9]*) \(.*\) (.*)\z/; + my $observed_pid = $1; + my @fields = split /\s+/, $2; + exit 1 unless @fields >= 20; + my ($state, $parent, $group, $start) = @fields[0, 1, 2, 19]; + $state = uc $state; + exit 1 unless $observed_pid eq $pid && $state ne "Z" && + $parent eq $expected_parent && $group =~ /\A[1-9][0-9]*\z/ && + $start =~ /\A[1-9][0-9]*\z/; + print "P:$pid:$parent:$group:$start\n"; + ' "$cmux_ssh_auth_tree_root_pid" "$cmux_ssh_auth_tree_root_parent" 2>/dev/null + ) || cmux_ssh_auth_root_termination_identity= + fi + ;; + esac + case "$cmux_ssh_auth_root_termination_identity" in + D:*|P:*) ;; + *) cmux_ssh_auth_root_termination_identity= ;; + esac + } cmux_ssh_auth_force_root_termination() { - case "$cmux_ssh_auth_tree_root_pid" in - ''|*[!0-9]*) return 0 ;; + case "$cmux_ssh_auth_root_termination_identity" in + D:*) + /usr/bin/ruby -rfiddle -rfiddle/import -e ' + token = ARGV[0].to_s.split(":", -1) + exit 0 unless token.length == 7 && token[0] == "D" + begin + pid, parent, group, seconds, microseconds, version = token.drop(1).map(&:to_i) + rescue ArgumentError, TypeError + exit 0 + end + exit 0 unless pid > 0 && parent >= 0 && group > 0 && seconds > 0 && + microseconds >= 0 && microseconds < 1_000_000 && version > 0 + + module CmuxLibproc + extend Fiddle::Importer + dlload "/usr/lib/libproc.dylib" + extern "int proc_pidinfo(int, int, unsigned long long, void*, int)" + extern "int proc_signal_with_audittoken(void*, int)" + end + size = 192 + buffer = Fiddle::Pointer.malloc(size) + written = CmuxLibproc.proc_pidinfo(pid, 18, 0, buffer, size) + exit 0 unless written == size + bytes = buffer.to_s(size) + uint32 = ->(offset) { bytes.byteslice(offset, 4).unpack1("L<") } + uint64 = ->(offset) { bytes.byteslice(offset, 8).unpack1("Q<") } + observed = [ + uint32.call(12), uint32.call(16), uint32.call(100), uint32.call(4), + uint64.call(120), uint64.call(128), uint32.call(168) + ] + expected = [pid, parent, group, nil, seconds, microseconds, version] + exit 0 unless observed[0] == expected[0] && observed[1] == expected[1] && + observed[2] == expected[2] && observed[3] != 5 && observed[4] == expected[4] && + observed[5] == expected[5] && observed[6] == expected[6] + audit_token = Fiddle::Pointer.malloc(32) + audit_token[0, 32] = ([0xffffffff] * 8).pack("L<*") + audit_token[20, 4] = [pid].pack("L<") + audit_token[28, 4] = [version].pack("L<") + [19, 15, 9].each do |signal_number| + CmuxLibproc.proc_signal_with_audittoken(audit_token, signal_number) + end + ' "$cmux_ssh_auth_root_termination_identity" >/dev/null 2>&1 || true + ;; + P:*) + if [ -n "$cmux_ssh_auth_perl_command" ]; then + "$cmux_ssh_auth_perl_command" -e ' + use strict; + use warnings; + my ($token) = @ARGV; + my ($kind, $pid, $parent, $group, $start) = split /:/, $token, -1; + exit 0 unless defined $kind && $kind eq "P" && + defined $pid && $pid =~ /\A[1-9][0-9]*\z/ && + defined $parent && $parent =~ /\A[0-9]+\z/ && + defined $group && $group =~ /\A[1-9][0-9]*\z/ && + defined $start && $start =~ /\A[1-9][0-9]*\z/; + sub read_identity { + my ($candidate_pid) = @_; + open my $input, "<", "/proc/$candidate_pid/stat" or return; + my $line = <$input>; + close $input; + chomp $line if defined $line; + return unless defined $line && + $line =~ /\A([1-9][0-9]*) \(.*\) (.*)\z/; + my $observed_pid = $1; + my @fields = split /\s+/, $2; + return unless @fields >= 20; + my ($state, $observed_parent, $observed_group, $observed_start) = + @fields[0, 1, 2, 19]; + return unless $observed_pid eq $candidate_pid && uc($state) ne "Z" && + $observed_parent =~ /\A[0-9]+\z/ && + $observed_group =~ /\A[1-9][0-9]*\z/ && + $observed_start =~ /\A[1-9][0-9]*\z/; + return [$observed_parent, $observed_group, $observed_start]; + } + my $matches = sub { + my ($identity) = @_; + return defined $identity && $identity->[0] eq $parent && + $identity->[1] eq $group && $identity->[2] eq $start; + }; + my $identity = read_identity($pid); + exit 0 unless $matches->($identity); + my $pidfd = syscall(434, $pid, 0); + exit 0 if $pidfd < 0; + my $after_open = read_identity($pid); + unless ($matches->($after_open)) { + close $pidfd; + exit 0; + } + for my $signal_number (18, 15, 9) { + syscall(424, $pidfd, $signal_number, 0, 0); + } + close $pidfd; + ' "$cmux_ssh_auth_root_termination_identity" >/dev/null 2>&1 || true + fi + ;; esac - if kill -0 "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1; then - kill -CONT "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1 || true - kill -TERM "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1 || true - kill -KILL "$cmux_ssh_auth_tree_root_pid" >/dev/null 2>&1 || true - fi } cmux_ssh_auth_early_cleanup() { trap - EXIT HUP INT TERM @@ -147,6 +305,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_setup_failed=1 exit 0 } + cmux_ssh_auth_capture_root_termination_identity # SECONDS is provided by the /bin/sh used by the generated launchers # and avoids one fork per deadline check. The pass limits below are a @@ -292,6 +451,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { my $line = <$input>; close $input; next unless defined $line; + chomp $line; # The command name may contain spaces and closing parens. The # fields after the final close-parenthesis have the stable # procfs layout. @@ -304,7 +464,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { $parent =~ /\A[0-9]+\z/ && $group =~ /\A[1-9][0-9]*\z/ && $start =~ /\A[1-9][0-9]*\z/; $state = uc $state; - print "$pid $parent $group $state P_${start}_0_0_0_0\n"; + print "$pid $parent $group $state P_${start} 0 0 0 0\n"; } ' > "$cmux_ssh_auth_snapshot" 2>/dev/null; then cmux_ssh_auth_cleanup_needs_root_abort=1 @@ -532,17 +692,19 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # snapshot is fenced again with the kernel audit token, which carries # the PID version. Linux uses a fresh procfs snapshot and the shell's # validated signal builtin instead of loading libproc. - # STOP candidates are checked again after the signal and only a - # confirmed stop enters the ownership journal. TERM and KILL are sent - # only to a confirmed stopped identity. A stopped process cannot exit - # and reuse its PID, which also closes the destructive KILL window. + # STOP candidates are journaled after the identity-checked request. + # A confirming snapshot must prove the stopped state before TERM or + # KILL. A stopped process cannot exit and reuse its PID, which closes + # the destructive KILL window. cmux_ssh_auth_signal_portable_batch() { cmux_ssh_auth_portable_signal_name="$1" cmux_ssh_auth_portable_signal_input="$2" cmux_ssh_auth_portable_signal_output="${3:-/dev/null}" + cmux_ssh_auth_portable_filter_stopped="${4:-1}" case "$cmux_ssh_auth_portable_signal_name" in STOP) cmux_ssh_auth_portable_require_stopped=0 ;; - TERM|CONT|KILL) cmux_ssh_auth_portable_require_stopped=1 ;; + TERM|KILL) cmux_ssh_auth_portable_require_stopped=1 ;; + CONT) cmux_ssh_auth_portable_require_stopped="$cmux_ssh_auth_portable_filter_stopped" ;; *) return 2 ;; esac cmux_ssh_auth_portable_candidates="$cmux_ssh_auth_state_dir/portable-candidates" @@ -583,10 +745,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { fi ;; CONT) - case "$cmux_ssh_auth_portable_state" in *T*) - kill -CONT "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1 || cmux_ssh_auth_portable_failed=1 - ;; - esac + kill -CONT "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1 || cmux_ssh_auth_portable_failed=1 ;; KILL) kill -KILL "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1 || cmux_ssh_auth_portable_failed=1 @@ -601,11 +760,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_signal_name="$1" cmux_ssh_auth_signal_input="$2" cmux_ssh_auth_signal_output="${3:-/dev/null}" + cmux_ssh_auth_signal_filter_stopped="${4:-1}" if [ "$cmux_ssh_auth_signal_backend" != darwin ]; then cmux_ssh_auth_signal_portable_batch \ "$cmux_ssh_auth_signal_name" \ "$cmux_ssh_auth_signal_input" \ - "$cmux_ssh_auth_signal_output" + "$cmux_ssh_auth_signal_output" \ + "$cmux_ssh_auth_signal_filter_stopped" cmux_ssh_auth_signal_status=$? if [ "$cmux_ssh_auth_signal_status" -ne 0 ]; then cmux_ssh_auth_cleanup_needs_root_abort=1 @@ -749,7 +910,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { } cmux_ssh_auth_resume_kernel_journal() { - cmux_ssh_auth_signal_verified_batch CONT "$1" /dev/null || true + # A STOP may still be in flight when the confirming snapshot runs. + # Include matching running rows so CONT cancels that delayed stop. + cmux_ssh_auth_signal_verified_batch CONT "$1" /dev/null 0 || true } cmux_ssh_auth_resume_unconfirmed_stops() { From f6dfb8a56656f69430f200e6b9755d1a5cecafb0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:07:47 -0700 Subject: [PATCH 41/61] fix: bound SSH cleanup and harden marker ownership --- ...HForegroundAuthenticationRetryPolicy.swift | 129 +++++++++++++----- 1 file changed, 98 insertions(+), 31 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 8e2ca853bcea..ef31a58a0001 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -124,7 +124,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_state_dir= cmux_ssh_auth_platform="$(uname -s 2>/dev/null || true)" cmux_ssh_auth_root_termination_identity= - cmux_ssh_auth_perl_command= + cmux_ssh_auth_perl_command="$(command -v perl 2>/dev/null || true)" cmux_ssh_auth_capture_root_termination_identity() { case "$cmux_ssh_auth_platform" in Darwin) @@ -159,7 +159,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { ) || cmux_ssh_auth_root_termination_identity= ;; *) - cmux_ssh_auth_perl_command=$(command -v perl 2>/dev/null || true) if [ -n "$cmux_ssh_auth_perl_command" ] && [ -r "/proc/$cmux_ssh_auth_tree_root_pid/stat" ]; then cmux_ssh_auth_root_termination_identity=$( @@ -307,12 +306,41 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { } cmux_ssh_auth_capture_root_termination_identity - # SECONDS is provided by the /bin/sh used by the generated launchers - # and avoids one fork per deadline check. The pass limits below are a - # second bound if an older shell does not update it. - SECONDS=0 + # Use Perl's monotonic clock for the shared cleanup budget. This is + # independent of shell extensions such as bash's SECONDS, which are + # absent from common POSIX shells. A failed clock probe stops the + # cleanup pass; the bounded pass count is the final fallback. + cmux_ssh_auth_cleanup_clock_command="$cmux_ssh_auth_perl_command" + cmux_ssh_auth_cleanup_deadline_millis= + if [ -n "$cmux_ssh_auth_cleanup_clock_command" ]; then + cmux_ssh_auth_cleanup_deadline_millis=$( + "$cmux_ssh_auth_cleanup_clock_command" \ + -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC \ + -e 'printf "%d\\n", int(clock_gettime(CLOCK_MONOTONIC) * 1000)' \ + 2>/dev/null + ) || cmux_ssh_auth_cleanup_deadline_millis= + case "$cmux_ssh_auth_cleanup_deadline_millis" in + ''|*[!0-9]*) cmux_ssh_auth_cleanup_deadline_millis= ;; + *) cmux_ssh_auth_cleanup_deadline_millis=$((cmux_ssh_auth_cleanup_deadline_millis + 2000)) ;; + esac + fi + cmux_ssh_auth_cleanup_fallback_checks=0 cmux_ssh_auth_cleanup_has_time() { - [ "${SECONDS:-0}" -lt 2 ] + if [ -n "$cmux_ssh_auth_cleanup_deadline_millis" ]; then + cmux_ssh_auth_cleanup_now_millis=$( + "$cmux_ssh_auth_cleanup_clock_command" \ + -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC \ + -e 'printf "%d\\n", int(clock_gettime(CLOCK_MONOTONIC) * 1000)' \ + 2>/dev/null + ) || return 1 + case "$cmux_ssh_auth_cleanup_now_millis" in + ''|*[!0-9]*) return 1 ;; + esac + [ "$cmux_ssh_auth_cleanup_now_millis" -lt "$cmux_ssh_auth_cleanup_deadline_millis" ] + return $? + fi + cmux_ssh_auth_cleanup_fallback_checks=$((cmux_ssh_auth_cleanup_fallback_checks + 1)) + [ "$cmux_ssh_auth_cleanup_fallback_checks" -le 4 ] } umask 077 || cmux_ssh_auth_setup_abort cmux_ssh_auth_state_dir=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/cmux-ssh-auth-tree.XXXXXX") || cmux_ssh_auth_setup_abort @@ -342,8 +370,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_term_event_fifo= cmux_ssh_auth_term_event_ack_fifo= cmux_ssh_auth_marker_path= + cmux_ssh_auth_marker_identity_path= if [ -n "$cmux_ssh_auth_event_token" ]; then cmux_ssh_auth_marker_path="${TMPDIR:-/tmp}/cmux-ssh-auth-marker.$cmux_ssh_auth_event_token" + cmux_ssh_auth_marker_identity_path="$cmux_ssh_auth_marker_path.identity" cmux_ssh_auth_term_event_fifo="$cmux_ssh_auth_term_event_dir/done" cmux_ssh_auth_term_event_ack_fifo="$cmux_ssh_auth_term_event_dir/ack" fi @@ -550,18 +580,51 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # A TERM handler can create a new session, exit, and leave its # replacement reparented before the next process-table snapshot. The - # classifier therefore keeps a per-attempt marker FD open. `lsof` - # returns the exact processes that inherited that FD, including a - # detached replacement. Record their PID, parent, PGID, and kernel - # start identity, then follow only their current descendants. A random - # marker token and the inherited descriptor are the ownership proof; - # no numeric process-group reuse can authorize an unrelated process. + # classifier therefore keeps a per-attempt marker FD open. The nested + # shell unlinks the marker after opening descriptor 7. `lsof` then + # matches the anonymous file by device and inode, including a detached + # replacement that inherited the descriptor. Record its PID, parent, + # PGID, and kernel start identity, then follow only current descendants. + # No pathname opener or numeric process-group reuse can authorize an + # unrelated process. cmux_ssh_auth_record_dynamic_members() { cmux_ssh_auth_take_snapshot || return 1 : > "$cmux_ssh_auth_marker_holders" || return 1 - if [ -n "$cmux_ssh_auth_marker_path" ] && [ -f "$cmux_ssh_auth_marker_path" ]; then - /usr/sbin/lsof -n -w -t -- "$cmux_ssh_auth_marker_path" \ - > "$cmux_ssh_auth_marker_holders" 2>/dev/null || : > "$cmux_ssh_auth_marker_holders" + if [ -s "$cmux_ssh_auth_marker_identity_path" ]; then + cmux_ssh_auth_marker_device= + cmux_ssh_auth_marker_inode= + if IFS=' ' read -r cmux_ssh_auth_marker_device cmux_ssh_auth_marker_inode \ + < "$cmux_ssh_auth_marker_identity_path" && + [ -n "$cmux_ssh_auth_marker_device" ] && + [ -n "$cmux_ssh_auth_marker_inode" ]; then + # The marker is unlinked after the authentication shell opens + # descriptor 7. Match the anonymous file by device and inode, + # so a pathname opener cannot seed destructive ownership. + /usr/sbin/lsof -n -w -a -d 7 -F pfiD 2>/dev/null | + /usr/bin/awk \ + -v cmux_marker_device="$cmux_ssh_auth_marker_device" \ + -v cmux_marker_inode="$cmux_ssh_auth_marker_inode" ' + /^p[0-9]+$/ { + cmux_lsof_pid = substr($0, 2) + cmux_lsof_fd = "" + cmux_lsof_device = "" + next + } + /^f/ { + cmux_lsof_fd = substr($0, 2) + cmux_lsof_device = "" + next + } + /^D/ { cmux_lsof_device = substr($0, 2); next } + /^i/ { + if (cmux_lsof_fd ~ /^7/ && + cmux_lsof_device == cmux_marker_device && + substr($0, 2) == cmux_marker_inode) { + print cmux_lsof_pid + } + } + ' > "$cmux_ssh_auth_marker_holders" || : > "$cmux_ssh_auth_marker_holders" + fi fi /usr/bin/awk ' FILENAME == ARGV[1] { @@ -660,13 +723,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { fi return 0 fi - # macOS /bin/sh accepts only an integer read timeout. Start this - # bounded grace after TERM so scheduler pressure can delay the - # handler without blocking cleanup forever. The FIFO stays open - # across retries. - cmux_ssh_auth_term_event_wait_start=${SECONDS:-0} + # macOS /bin/sh accepts only an integer read timeout. Five bounded + # reads give scheduler pressure time to deliver the handler without + # relying on a non-POSIX shell timer. The FIFO stays open across + # retries. + cmux_ssh_auth_term_event_wait_attempt=0 while [ "$cmux_ssh_auth_term_event_received" != 1 ] && - [ "$((${SECONDS:-0} - cmux_ssh_auth_term_event_wait_start))" -lt 5 ]; do + [ "$cmux_ssh_auth_term_event_wait_attempt" -lt 5 ]; do if IFS= read -r -t 1 cmux_ssh_auth_term_event_writer <&9; then # The FIFO directory and payload both carry the random, # per-attempt nonce. Process ownership is established by the @@ -675,6 +738,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_term_event_received=1 fi fi + cmux_ssh_auth_term_event_wait_attempt=$((cmux_ssh_auth_term_event_wait_attempt + 1)) done if [ "$cmux_ssh_auth_term_event_received" != 1 ]; then exec 9>&- @@ -975,12 +1039,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { fi fi # Once the wrapper opens the per-attempt event FIFOs, marker - # cleanup is handed to this helper. The wrapper may time out while - # waiting for the ACK, but the marker must stay linked until every - # post-TERM discovery pass has finished so `lsof` can still prove - # ownership of a detached replacement. + # identity cleanup is handed to this helper. The wrapper may time + # out while waiting for the ACK, but the identity record must stay + # available until every post-TERM discovery pass has finished. if [ "$cmux_ssh_auth_term_event_owned" = 1 ] && [ -n "$cmux_ssh_auth_marker_path" ]; then - /bin/rm -f -- "$cmux_ssh_auth_marker_path" 2>/dev/null || true + /bin/rm -f -- "$cmux_ssh_auth_marker_path" "$cmux_ssh_auth_marker_identity_path" 2>/dev/null || true fi if [ "$cmux_ssh_auth_term_event_owned" = 1 ]; then /bin/rm -f "$cmux_ssh_auth_term_event_fifo" "$cmux_ssh_auth_term_event_ack_fifo" 2>/dev/null || true @@ -1268,7 +1331,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { if [ -f "$cmux_ssh_auth_marker_path" ]; then # zsh's managed descriptors are close-on-exec. Use a fixed # descriptor so the marker survives the nested env/zsh exec. - exec 7<> "$cmux_ssh_auth_marker_path" 2>/dev/null || true + if exec 7<> "$cmux_ssh_auth_marker_path" 2>/dev/null; then + # Unlink the marker after the inherited descriptor is open. + # The helper uses the saved device and inode, not this path. + /bin/rm -f -- "$cmux_ssh_auth_marker_path" 2>/dev/null || true + fi fi ;; esac @@ -1310,8 +1377,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { // path from their unrelated `$$` values. "cmux_ssh_auth_event_token=\"${CMUX_SSH_AUTH_EVENT_TOKEN:-}\"", "case \"$cmux_ssh_auth_event_token\" in ''|*[!A-Za-z0-9_-]*) cmux_ssh_auth_event_token= ;; esac", - "cmux_ssh_auth_term_event_fifo=; cmux_ssh_auth_term_event_ack_fifo=; cmux_ssh_auth_marker_path=; cmux_ssh_auth_marker_owned=0; cmux_ssh_auth_marker_cleanup_deferred=0", - "if [ -n \"$cmux_ssh_auth_event_token\" ]; then cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/done\"; cmux_ssh_auth_term_event_ack_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/ack\"; cmux_ssh_auth_marker_path=\"${TMPDIR:-/tmp}/cmux-ssh-auth-marker.$cmux_ssh_auth_event_token\"; if ( set -C; : > \"$cmux_ssh_auth_marker_path\" ) 2>/dev/null; then if exec 7<> \"$cmux_ssh_auth_marker_path\" 2>/dev/null; then cmux_ssh_auth_marker_owned=1; else /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true; fi; fi; fi", + "cmux_ssh_auth_term_event_fifo=; cmux_ssh_auth_term_event_ack_fifo=; cmux_ssh_auth_marker_path=; cmux_ssh_auth_marker_identity_path=; cmux_ssh_auth_marker_owned=0; cmux_ssh_auth_marker_cleanup_deferred=0", + "if [ -n \"$cmux_ssh_auth_event_token\" ]; then cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/done\"; cmux_ssh_auth_term_event_ack_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/ack\"; cmux_ssh_auth_marker_path=\"${TMPDIR:-/tmp}/cmux-ssh-auth-marker.$cmux_ssh_auth_event_token\"; cmux_ssh_auth_marker_identity_path=\"$cmux_ssh_auth_marker_path.identity\"; if ( set -C; : > \"$cmux_ssh_auth_marker_path\" ) 2>/dev/null; then if exec 7<> \"$cmux_ssh_auth_marker_path\" 2>/dev/null; then cmux_ssh_auth_marker_device=$(/usr/bin/stat -f '%d' \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true); cmux_ssh_auth_marker_inode=$(/usr/bin/stat -f '%i' \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true); cmux_ssh_auth_marker_device_hex=$(/usr/bin/printf '0x%x' \"$cmux_ssh_auth_marker_device\" 2>/dev/null || true); if case \"$cmux_ssh_auth_marker_device:$cmux_ssh_auth_marker_inode\" in ''|*[!0-9:]*|:*|*:) false ;; *) true ;; esac && [ -n \"$cmux_ssh_auth_marker_device_hex\" ] && ( set -C; /usr/bin/printf '%s %s\\n' \"$cmux_ssh_auth_marker_device_hex\" \"$cmux_ssh_auth_marker_inode\" > \"$cmux_ssh_auth_marker_identity_path\" ) 2>/dev/null; then cmux_ssh_auth_marker_owned=1; else exec 7>&-; /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" \"$cmux_ssh_auth_marker_identity_path\" 2>/dev/null || true; fi; else /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true; fi; fi; fi", // Open both FIFO endpoints before waiting for the command. The // helper can then enter a bounded read without blocking on FIFO // setup, while the completion payload still has a happens-before @@ -1332,7 +1399,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { " wait \"$cmux_ssh_auth_capture_pid\" 2>/dev/null || true", " fi", " done", - " if [ \"${cmux_ssh_auth_marker_owned:-0}\" = 1 ]; then exec 7>&-; if [ \"${cmux_ssh_auth_marker_cleanup_deferred:-0}\" != 1 ]; then /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true; fi; cmux_ssh_auth_marker_owned=0; fi", + " if [ \"${cmux_ssh_auth_marker_owned:-0}\" = 1 ]; then exec 7>&-; if [ \"${cmux_ssh_auth_marker_cleanup_deferred:-0}\" != 1 ]; then /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" \"$cmux_ssh_auth_marker_identity_path\" 2>/dev/null || true; fi; cmux_ssh_auth_marker_owned=0; fi", " /bin/rm -f -- \"$cmux_ssh_auth_classifier_fifo\" \"$cmux_ssh_auth_capture_state\" 2>/dev/null || true", "}", "cmux_ssh_auth_capture_signal_exit() {", From 17475c3b6174b5710d2a17886dc7f689d2e4b421 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:53:11 -0700 Subject: [PATCH 42/61] fix: harden cross-platform SSH cleanup signals --- ...HForegroundAuthenticationRetryPolicy.swift | 253 ++++++++++++++---- 1 file changed, 197 insertions(+), 56 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index ef31a58a0001..470ffcb52b90 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -125,6 +125,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_platform="$(uname -s 2>/dev/null || true)" cmux_ssh_auth_root_termination_identity= cmux_ssh_auth_perl_command="$(command -v perl 2>/dev/null || true)" + cmux_ssh_auth_lsof_command="$(command -v lsof 2>/dev/null || true)" cmux_ssh_auth_capture_root_termination_identity() { case "$cmux_ssh_auth_platform" in Darwin) @@ -139,6 +140,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { expected_parent = Integer(ARGV[1]) size = 192 buffer = Fiddle::Pointer.malloc(size) + # XNU names flavor 18 PROC_PIDT_BSDINFOWITHUNIQID. It + # returns proc_bsdinfo (136 bytes) followed by + # proc_uniqidentifierinfo (56 bytes), for 192 bytes. written = CmuxLibproc.proc_pidinfo(pid, 18, 0, buffer, size) exit 1 unless written == size bytes = buffer.to_s(size) @@ -240,13 +244,20 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_ssh_auth_perl_command" -e ' use strict; use warnings; + use POSIX (); my ($token) = @ARGV; my ($kind, $pid, $parent, $group, $start) = split /:/, $token, -1; exit 0 unless defined $kind && $kind eq "P" && defined $pid && $pid =~ /\A[1-9][0-9]*\z/ && - defined $parent && $parent =~ /\A[0-9]+\z/ && + defined $parent && $parent =~ /\A[0-9]+\z/ && defined $group && $group =~ /\A[1-9][0-9]*\z/ && defined $start && $start =~ /\A[1-9][0-9]*\z/; + my $pid_number = int($pid); + # Linux exposes pidfd_open and pidfd_send_signal at these + # stable syscall numbers. If this kernel does not provide + # them, fail closed instead of sending to a bare PID. + my $pidfd_open_syscall = 434; + my $pidfd_send_signal_syscall = 424; sub read_identity { my ($candidate_pid) = @_; open my $input, "<", "/proc/$candidate_pid/stat" or return; @@ -273,17 +284,19 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { }; my $identity = read_identity($pid); exit 0 unless $matches->($identity); - my $pidfd = syscall(434, $pid, 0); - exit 0 if $pidfd < 0; + # Force the validated PID to an integer. Perl can pass a + # string scalar as a pointer to syscall on 64-bit hosts. + my $pidfd = syscall($pidfd_open_syscall, $pid_number, 0); + exit 0 unless defined $pidfd && $pidfd >= 0; my $after_open = read_identity($pid); unless ($matches->($after_open)) { - close $pidfd; + POSIX::close($pidfd); exit 0; } for my $signal_number (18, 15, 9) { - syscall(424, $pidfd, $signal_number, 0, 0); + syscall($pidfd_send_signal_syscall, $pidfd, $signal_number, 0, 0); } - close $pidfd; + POSIX::close($pidfd); ' "$cmux_ssh_auth_root_termination_identity" >/dev/null 2>&1 || true fi ;; @@ -316,7 +329,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_cleanup_deadline_millis=$( "$cmux_ssh_auth_cleanup_clock_command" \ -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC \ - -e 'printf "%d\\n", int(clock_gettime(CLOCK_MONOTONIC) * 1000)' \ + -e 'printf "%d\n", int(clock_gettime(CLOCK_MONOTONIC) * 1000)' \ 2>/dev/null ) || cmux_ssh_auth_cleanup_deadline_millis= case "$cmux_ssh_auth_cleanup_deadline_millis" in @@ -330,7 +343,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_cleanup_now_millis=$( "$cmux_ssh_auth_cleanup_clock_command" \ -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC \ - -e 'printf "%d\\n", int(clock_gettime(CLOCK_MONOTONIC) * 1000)' \ + -e 'printf "%d\n", int(clock_gettime(CLOCK_MONOTONIC) * 1000)' \ 2>/dev/null ) || return 1 case "$cmux_ssh_auth_cleanup_now_millis" in @@ -591,18 +604,22 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_take_snapshot || return 1 : > "$cmux_ssh_auth_marker_holders" || return 1 if [ -s "$cmux_ssh_auth_marker_identity_path" ]; then + cmux_ssh_auth_marker_device_hex= cmux_ssh_auth_marker_device= cmux_ssh_auth_marker_inode= - if IFS=' ' read -r cmux_ssh_auth_marker_device cmux_ssh_auth_marker_inode \ + if IFS=' ' read -r cmux_ssh_auth_marker_device_hex cmux_ssh_auth_marker_device cmux_ssh_auth_marker_inode \ < "$cmux_ssh_auth_marker_identity_path" && + [ -n "$cmux_ssh_auth_marker_device_hex" ] && [ -n "$cmux_ssh_auth_marker_device" ] && [ -n "$cmux_ssh_auth_marker_inode" ]; then # The marker is unlinked after the authentication shell opens # descriptor 7. Match the anonymous file by device and inode, # so a pathname opener cannot seed destructive ownership. - /usr/sbin/lsof -n -w -a -d 7 -F pfiD 2>/dev/null | + if [ -n "$cmux_ssh_auth_lsof_command" ]; then + "$cmux_ssh_auth_lsof_command" -n -w -a -d 7 -F pfiD 2>/dev/null | /usr/bin/awk \ -v cmux_marker_device="$cmux_ssh_auth_marker_device" \ + -v cmux_marker_device_hex="$cmux_ssh_auth_marker_device_hex" \ -v cmux_marker_inode="$cmux_ssh_auth_marker_inode" ' /^p[0-9]+$/ { cmux_lsof_pid = substr($0, 2) @@ -618,12 +635,14 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { /^D/ { cmux_lsof_device = substr($0, 2); next } /^i/ { if (cmux_lsof_fd ~ /^7/ && - cmux_lsof_device == cmux_marker_device && + (cmux_lsof_device == cmux_marker_device || + cmux_lsof_device == cmux_marker_device_hex) && substr($0, 2) == cmux_marker_inode) { print cmux_lsof_pid } } ' > "$cmux_ssh_auth_marker_holders" || : > "$cmux_ssh_auth_marker_holders" + fi fi fi /usr/bin/awk ' @@ -693,7 +712,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { [ "$cmux_ssh_auth_wait_for_term_event_enabled" = 1 ] || return 0 [ -n "$cmux_ssh_auth_event_token" ] || return 0 if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then return 0; fi - if [ ! -p "$cmux_ssh_auth_term_event_ack_fifo" ] || ! exec 10<> "$cmux_ssh_auth_term_event_ack_fifo"; then return 0; fi + # Keep both descriptors below 10 because POSIX sh does not + # require multi-digit redirection operands. + if [ ! -p "$cmux_ssh_auth_term_event_ack_fifo" ] || ! exec 8<> "$cmux_ssh_auth_term_event_ack_fifo"; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 cmux_ssh_auth_term_event_writer= if [ "$cmux_ssh_auth_signal_backend" != darwin ]; then @@ -747,15 +768,15 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_ack_term_event() { if [ "$cmux_ssh_auth_term_event_received" = 1 ]; then - printf '%s\n' "$cmux_ssh_auth_event_token" >&10 2>/dev/null || true + printf '%s\n' "$cmux_ssh_auth_event_token" >&8 2>/dev/null || true fi exec 9>&- } # Validate and signal an identity batch. On Darwin, the shell/awk # snapshot is fenced again with the kernel audit token, which carries - # the PID version. Linux uses a fresh procfs snapshot and the shell's - # validated signal builtin instead of loading libproc. + # the PID version. Linux uses a fresh procfs snapshot and pidfds so a + # PID cannot be reused between validation and signal delivery. # STOP candidates are journaled after the identity-checked request. # A confirming snapshot must prove the stopped state before TERM or # KILL. A stopped process cannot exit and reuse its PID, which closes @@ -779,45 +800,122 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_ssh_auth_portable_require_stopped"; then return 1 fi - cmux_ssh_auth_portable_failed=0 - while IFS=' ' read -r cmux_ssh_auth_portable_depth \ - cmux_ssh_auth_portable_pid cmux_ssh_auth_portable_parent \ - cmux_ssh_auth_portable_group cmux_ssh_auth_portable_state \ - cmux_ssh_auth_portable_started; do - case "$cmux_ssh_auth_portable_pid" in - ''|*[!0-9]*) continue ;; - esac - case "$cmux_ssh_auth_portable_signal_name" in - STOP) - case "$cmux_ssh_auth_portable_state" in *T*) continue ;; esac - if kill -STOP "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1; then - printf '%s\n' \ - "$cmux_ssh_auth_portable_depth $cmux_ssh_auth_portable_pid $cmux_ssh_auth_portable_parent $cmux_ssh_auth_portable_group $cmux_ssh_auth_portable_state $cmux_ssh_auth_portable_started" \ - >> "$cmux_ssh_auth_portable_signal_output" || cmux_ssh_auth_portable_failed=1 - else - cmux_ssh_auth_portable_failed=1 - fi - ;; - TERM) - if kill -TERM "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1; then - kill -CONT "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1 || true - else - # A failed TERM must not strand a process that this helper - # stopped. The caller will retry the identity-checked CONT. - kill -CONT "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1 || true - cmux_ssh_auth_portable_failed=1 - fi - ;; - CONT) - kill -CONT "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1 || cmux_ssh_auth_portable_failed=1 - ;; - KILL) - kill -KILL "$cmux_ssh_auth_portable_pid" >/dev/null 2>&1 || cmux_ssh_auth_portable_failed=1 - ;; - esac - done < "$cmux_ssh_auth_portable_candidates" + if [ -z "$cmux_ssh_auth_perl_command" ]; then + /bin/rm -f "$cmux_ssh_auth_portable_candidates" 2>/dev/null || true + return 1 + fi + "$cmux_ssh_auth_perl_command" -e ' + use strict; + use warnings; + use POSIX (); + my ($signal_name, $input_path, $output_path, $require_stopped) = @ARGV; + my %signals = (STOP => 19, TERM => 15, CONT => 18, KILL => 9); + # Linux exposes pidfd_open and pidfd_send_signal at these stable + # syscall numbers. If this kernel does not provide them, report + # failure so the caller never falls back to a bare PID signal. + my $pidfd_open_syscall = 434; + my $pidfd_send_signal_syscall = 424; + exit 2 unless exists $signals{$signal_name}; + + sub read_identity { + my ($candidate_pid) = @_; + open my $input, "<", "/proc/$candidate_pid/stat" or return; + my $line = <$input>; + close $input; + chomp $line if defined $line; + return unless defined $line && + $line =~ /\A([1-9][0-9]*) \(.*\) (.*)\z/; + my $observed_pid = $1; + my @fields = split /\s+/, $2; + return unless @fields >= 20; + my ($state, $parent, $group, $start) = @fields[0, 1, 2, 19]; + $state = uc $state; + return unless $observed_pid eq $candidate_pid && + $state ne "Z" && $parent =~ /\A[0-9]+\z/ && + $group =~ /\A[1-9][0-9]*\z/ && $start =~ /\A[1-9][0-9]*\z/; + return [$state, $parent, $group, $start]; + } + sub matches { + my ($identity, $parent, $group, $start) = @_; + return defined $identity && $identity->[1] eq $parent && + $identity->[2] eq $group && $identity->[3] eq $start; + } + + open my $input, "<", $input_path or exit 1; + open my $output, ">>", $output_path or exit 1; + my $failed = 0; + while (my $line = <$input>) { + chomp $line; + my @fields = split /\s+/, $line; + next unless @fields == 6; + my ($depth, $pid, $parent, $group, $original_state, $started) = @fields; + next unless $depth =~ /\A[0-9]+\z/ && $pid =~ /\A[1-9][0-9]*\z/ && + $parent =~ /\A[0-9]+\z/ && $group =~ /\A[1-9][0-9]*\z/ && + $started =~ /\AP_[0-9]+_0_0_0_0\z/; + my ($expected_start) = $started =~ /\AP_([0-9]+)_0_0_0_0\z/; + next unless defined $expected_start; + my $pid_number = int($pid); + my $identity = read_identity($pid); + next unless matches($identity, $parent, $group, $expected_start); + next if $signal_name eq "STOP" && $identity->[0] =~ /T/; + next if $signal_name eq "CONT" && $original_state =~ /T/; + next if $signal_name =~ /\A(?:TERM|KILL)\z/ && $identity->[0] !~ /T/; + next if $require_stopped eq "1" && $identity->[0] !~ /T/ && + $signal_name ne "CONT"; + # Force the validated PID to an integer. Perl can pass a + # string scalar as a pointer to syscall on 64-bit hosts. + my $pidfd = syscall($pidfd_open_syscall, $pid_number, 0); + if (!defined $pidfd || $pidfd < 0) { + $failed = 1; + next; + } + my $after_open = read_identity($pid); + unless (matches($after_open, $parent, $group, $expected_start)) { + POSIX::close($pidfd); + next; + } + my $send = sub { + my $result = syscall($pidfd_send_signal_syscall, $pidfd, $_[0], 0, 0); + defined($result) && $result == 0; + }; + if ($signal_name eq "STOP") { + if ($send->($signals{STOP})) { + print {$output} "$line\n" or $failed = 1; + } else { + $failed = 1; + } + } elsif ($signal_name eq "TERM") { + my $term_ok = $send->($signals{TERM}); + my $cont_ok = $send->($signals{CONT}); + unless ($term_ok && $cont_ok) { + # An exited target is already cleaned up. A surviving + # target means this batch did not complete and must stay + # on the bounded retry or root-abort path. + $failed = 1 if defined read_identity($pid); + } + } elsif ($signal_name eq "CONT") { + unless ($send->($signals{CONT})) { + my $current = read_identity($pid); + $failed = 1 if matches($current, $parent, $group, $expected_start) && + $current->[0] =~ /T/; + } + } elsif ($signal_name eq "KILL") { + unless ($send->($signals{KILL})) { + my $current = read_identity($pid); + $failed = 1 if matches($current, $parent, $group, $expected_start) && + $current->[0] =~ /T/; + } + } + POSIX::close($pidfd); + } + close $input; + close $output; + exit $failed ? 1 : 0; + ' "$cmux_ssh_auth_signal_name" "$cmux_ssh_auth_portable_candidates" \ + "$cmux_ssh_auth_portable_signal_output" "$cmux_ssh_auth_portable_require_stopped" + cmux_ssh_auth_portable_status=$? /bin/rm -f "$cmux_ssh_auth_portable_candidates" 2>/dev/null || true - [ "$cmux_ssh_auth_portable_failed" = 0 ] + [ "$cmux_ssh_auth_portable_status" -eq 0 ] } cmux_ssh_auth_signal_verified_batch() { @@ -1054,7 +1152,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # the event wait times out; unlinking first also wakes a reader # that races with cleanup. exec 9>&- 2>/dev/null || true - exec 10>&- 2>/dev/null || true + exec 8>&- 2>/dev/null || true /bin/rm -f "$cmux_ssh_auth_snapshot" "$cmux_ssh_auth_members" \ "$cmux_ssh_auth_pending" "$cmux_ssh_auth_owned" \ "$cmux_ssh_auth_live" "$cmux_ssh_auth_term" \ @@ -1084,7 +1182,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_term_event_owned=1 else exec 9>&- 2>/dev/null || true - exec 10>&- 2>/dev/null || true + exec 8>&- 2>/dev/null || true # The directory was created by this invocation. Remove only its # own partial setup. A mkdir collision never reaches this path, # so a stale attempt's FIFOs remain untouched. @@ -1364,6 +1462,49 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { } } """ + let markerSetup = """ + cmux_ssh_auth_marker_stat_command="$(command -v stat 2>/dev/null || true)" + if [ -n "$cmux_ssh_auth_event_token" ]; then + cmux_ssh_auth_term_event_fifo="${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/done" + cmux_ssh_auth_term_event_ack_fifo="${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/ack" + cmux_ssh_auth_marker_path="${TMPDIR:-/tmp}/cmux-ssh-auth-marker.$cmux_ssh_auth_event_token" + cmux_ssh_auth_marker_identity_path="$cmux_ssh_auth_marker_path.identity" + # Probe the host's stat implementation. GNU stat accepts -c and BSD + # stat accepts -f; the first successful probe supplies numeric device + # and inode values without assuming a platform-specific path. + if ( set -C; : > "$cmux_ssh_auth_marker_path" ) 2>/dev/null; then + if exec 7<> "$cmux_ssh_auth_marker_path" 2>/dev/null; then + cmux_ssh_auth_marker_device= + cmux_ssh_auth_marker_inode= + if [ -n "$cmux_ssh_auth_marker_stat_command" ]; then + cmux_ssh_auth_marker_device=$( + "$cmux_ssh_auth_marker_stat_command" -c '%d' "$cmux_ssh_auth_marker_path" 2>/dev/null || + "$cmux_ssh_auth_marker_stat_command" -f '%d' "$cmux_ssh_auth_marker_path" 2>/dev/null || + true + ) + cmux_ssh_auth_marker_inode=$( + "$cmux_ssh_auth_marker_stat_command" -c '%i' "$cmux_ssh_auth_marker_path" 2>/dev/null || + "$cmux_ssh_auth_marker_stat_command" -f '%i' "$cmux_ssh_auth_marker_path" 2>/dev/null || + true + ) + fi + cmux_ssh_auth_marker_device_hex=$(printf '0x%x' "$cmux_ssh_auth_marker_device" 2>/dev/null || true) + if case "$cmux_ssh_auth_marker_device:$cmux_ssh_auth_marker_inode" in + ''|*[!0-9:]*|:*|*:) false ;; + *) true ;; + esac && [ -n "$cmux_ssh_auth_marker_device_hex" ] && + ( set -C; printf '%s %s %s\n' "$cmux_ssh_auth_marker_device_hex" "$cmux_ssh_auth_marker_device" "$cmux_ssh_auth_marker_inode" > "$cmux_ssh_auth_marker_identity_path" ) 2>/dev/null; then + cmux_ssh_auth_marker_owned=1 + else + exec 7>&- + /bin/rm -f -- "$cmux_ssh_auth_marker_path" "$cmux_ssh_auth_marker_identity_path" 2>/dev/null || true + fi + else + /bin/rm -f -- "$cmux_ssh_auth_marker_path" 2>/dev/null || true + fi + fi + fi + """ let script = [ "umask 077", "cmux_ssh_auth_capture_state=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-auth.XXXXXX\") || exit 255", @@ -1378,7 +1519,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "cmux_ssh_auth_event_token=\"${CMUX_SSH_AUTH_EVENT_TOKEN:-}\"", "case \"$cmux_ssh_auth_event_token\" in ''|*[!A-Za-z0-9_-]*) cmux_ssh_auth_event_token= ;; esac", "cmux_ssh_auth_term_event_fifo=; cmux_ssh_auth_term_event_ack_fifo=; cmux_ssh_auth_marker_path=; cmux_ssh_auth_marker_identity_path=; cmux_ssh_auth_marker_owned=0; cmux_ssh_auth_marker_cleanup_deferred=0", - "if [ -n \"$cmux_ssh_auth_event_token\" ]; then cmux_ssh_auth_term_event_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/done\"; cmux_ssh_auth_term_event_ack_fifo=\"${TMPDIR:-/tmp}/cmux-ssh-auth-term.$cmux_ssh_auth_event_token/ack\"; cmux_ssh_auth_marker_path=\"${TMPDIR:-/tmp}/cmux-ssh-auth-marker.$cmux_ssh_auth_event_token\"; cmux_ssh_auth_marker_identity_path=\"$cmux_ssh_auth_marker_path.identity\"; if ( set -C; : > \"$cmux_ssh_auth_marker_path\" ) 2>/dev/null; then if exec 7<> \"$cmux_ssh_auth_marker_path\" 2>/dev/null; then cmux_ssh_auth_marker_device=$(/usr/bin/stat -f '%d' \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true); cmux_ssh_auth_marker_inode=$(/usr/bin/stat -f '%i' \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true); cmux_ssh_auth_marker_device_hex=$(/usr/bin/printf '0x%x' \"$cmux_ssh_auth_marker_device\" 2>/dev/null || true); if case \"$cmux_ssh_auth_marker_device:$cmux_ssh_auth_marker_inode\" in ''|*[!0-9:]*|:*|*:) false ;; *) true ;; esac && [ -n \"$cmux_ssh_auth_marker_device_hex\" ] && ( set -C; /usr/bin/printf '%s %s\\n' \"$cmux_ssh_auth_marker_device_hex\" \"$cmux_ssh_auth_marker_inode\" > \"$cmux_ssh_auth_marker_identity_path\" ) 2>/dev/null; then cmux_ssh_auth_marker_owned=1; else exec 7>&-; /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" \"$cmux_ssh_auth_marker_identity_path\" 2>/dev/null || true; fi; else /bin/rm -f -- \"$cmux_ssh_auth_marker_path\" 2>/dev/null || true; fi; fi; fi", + markerSetup, // Open both FIFO endpoints before waiting for the command. The // helper can then enter a bounded read without blocking on FIFO // setup, while the completion payload still has a happens-before From c653124f6815f4589e43f3005a3434ccf874a685 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:21:03 -0700 Subject: [PATCH 43/61] fix: bound SSH event discovery and fail closed --- ...HForegroundAuthenticationRetryPolicy.swift | 237 +++++++++++------- 1 file changed, 148 insertions(+), 89 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 470ffcb52b90..a1d39544ac39 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -338,7 +338,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { esac fi cmux_ssh_auth_cleanup_fallback_checks=0 - cmux_ssh_auth_cleanup_has_time() { + cmux_ssh_auth_get_remaining_millis() { if [ -n "$cmux_ssh_auth_cleanup_deadline_millis" ]; then cmux_ssh_auth_cleanup_now_millis=$( "$cmux_ssh_auth_cleanup_clock_command" \ @@ -349,11 +349,22 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { case "$cmux_ssh_auth_cleanup_now_millis" in ''|*[!0-9]*) return 1 ;; esac - [ "$cmux_ssh_auth_cleanup_now_millis" -lt "$cmux_ssh_auth_cleanup_deadline_millis" ] + cmux_ssh_auth_remaining_millis=$((cmux_ssh_auth_cleanup_deadline_millis - cmux_ssh_auth_cleanup_now_millis)) + [ "$cmux_ssh_auth_remaining_millis" -gt 0 ] return $? fi cmux_ssh_auth_cleanup_fallback_checks=$((cmux_ssh_auth_cleanup_fallback_checks + 1)) - [ "$cmux_ssh_auth_cleanup_fallback_checks" -le 4 ] + if [ "$cmux_ssh_auth_cleanup_fallback_checks" -le 4 ]; then + # Without a monotonic clock, keep the same bounded fallback used + # by cleanup_has_time and expose one conservative wait interval. + cmux_ssh_auth_remaining_millis=1000 + return 0 + fi + cmux_ssh_auth_remaining_millis=0 + return 1 + } + cmux_ssh_auth_cleanup_has_time() { + cmux_ssh_auth_get_remaining_millis } umask 077 || cmux_ssh_auth_setup_abort cmux_ssh_auth_state_dir=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/cmux-ssh-auth-tree.XXXXXX") || cmux_ssh_auth_setup_abort @@ -371,6 +382,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_root_identity_candidate="$cmux_ssh_auth_state_dir/root-identity-candidate" cmux_ssh_auth_dynamic_members="$cmux_ssh_auth_state_dir/dynamic-members" cmux_ssh_auth_marker_holders="$cmux_ssh_auth_state_dir/marker-holders" + cmux_ssh_auth_marker_lsof_output="$cmux_ssh_auth_state_dir/marker-lsof" cmux_ssh_auth_root_identity= cmux_ssh_auth_event_token="${4:-${CMUX_SSH_AUTH_EVENT_TOKEN:-}}" case "$cmux_ssh_auth_event_token" in @@ -395,6 +407,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_signal_backend=portable cmux_ssh_auth_snapshot_format= cmux_ssh_auth_cleanup_needs_root_abort=0 + cmux_ssh_auth_dynamic_discovery_failed=0 if [ "$(uname -s 2>/dev/null || true)" = Darwin ]; then cmux_ssh_auth_signal_backend=darwin fi @@ -600,52 +613,106 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # PGID, and kernel start identity, then follow only current descendants. # No pathname opener or numeric process-group reuse can authorize an # unrelated process. + cmux_ssh_auth_dynamic_discovery_abort() { + cmux_ssh_auth_dynamic_discovery_failed=1 + cmux_ssh_auth_cleanup_needs_root_abort=1 + return 1 + } cmux_ssh_auth_record_dynamic_members() { - cmux_ssh_auth_take_snapshot || return 1 - : > "$cmux_ssh_auth_marker_holders" || return 1 - if [ -s "$cmux_ssh_auth_marker_identity_path" ]; then + if ! cmux_ssh_auth_take_snapshot; then + cmux_ssh_auth_dynamic_discovery_abort + return 1 + fi + if ! : > "$cmux_ssh_auth_marker_holders"; then + cmux_ssh_auth_dynamic_discovery_abort + return 1 + fi + if [ -n "$cmux_ssh_auth_event_token" ]; then + if [ ! -s "$cmux_ssh_auth_marker_identity_path" ]; then + cmux_ssh_auth_dynamic_discovery_abort + return 1 + fi cmux_ssh_auth_marker_device_hex= cmux_ssh_auth_marker_device= cmux_ssh_auth_marker_inode= - if IFS=' ' read -r cmux_ssh_auth_marker_device_hex cmux_ssh_auth_marker_device cmux_ssh_auth_marker_inode \ - < "$cmux_ssh_auth_marker_identity_path" && - [ -n "$cmux_ssh_auth_marker_device_hex" ] && - [ -n "$cmux_ssh_auth_marker_device" ] && - [ -n "$cmux_ssh_auth_marker_inode" ]; then - # The marker is unlinked after the authentication shell opens - # descriptor 7. Match the anonymous file by device and inode, - # so a pathname opener cannot seed destructive ownership. - if [ -n "$cmux_ssh_auth_lsof_command" ]; then - "$cmux_ssh_auth_lsof_command" -n -w -a -d 7 -F pfiD 2>/dev/null | - /usr/bin/awk \ - -v cmux_marker_device="$cmux_ssh_auth_marker_device" \ - -v cmux_marker_device_hex="$cmux_ssh_auth_marker_device_hex" \ - -v cmux_marker_inode="$cmux_ssh_auth_marker_inode" ' - /^p[0-9]+$/ { - cmux_lsof_pid = substr($0, 2) - cmux_lsof_fd = "" - cmux_lsof_device = "" - next - } - /^f/ { - cmux_lsof_fd = substr($0, 2) - cmux_lsof_device = "" - next - } - /^D/ { cmux_lsof_device = substr($0, 2); next } - /^i/ { - if (cmux_lsof_fd ~ /^7/ && - (cmux_lsof_device == cmux_marker_device || - cmux_lsof_device == cmux_marker_device_hex) && - substr($0, 2) == cmux_marker_inode) { - print cmux_lsof_pid - } - } - ' > "$cmux_ssh_auth_marker_holders" || : > "$cmux_ssh_auth_marker_holders" - fi + if ! IFS=' ' read -r cmux_ssh_auth_marker_device_hex cmux_ssh_auth_marker_device cmux_ssh_auth_marker_inode \ + < "$cmux_ssh_auth_marker_identity_path" || + [ -z "$cmux_ssh_auth_marker_device_hex" ] || + [ -z "$cmux_ssh_auth_marker_device" ] || + [ -z "$cmux_ssh_auth_marker_inode" ]; then + cmux_ssh_auth_dynamic_discovery_abort + return 1 + fi + # The marker is unlinked after the authentication shell opens + # descriptor 7. Match the anonymous file by device and inode, + # so a pathname opener cannot seed destructive ownership. + if [ -z "$cmux_ssh_auth_lsof_command" ]; then + cmux_ssh_auth_dynamic_discovery_abort + return 1 + fi + if ! : > "$cmux_ssh_auth_marker_lsof_output"; then + cmux_ssh_auth_dynamic_discovery_abort + return 1 + fi + "$cmux_ssh_auth_lsof_command" -n -w -a -d 7 -F pfiD \ + > "$cmux_ssh_auth_marker_lsof_output" 2>/dev/null + cmux_ssh_auth_marker_lsof_status=$? + case "$cmux_ssh_auth_marker_lsof_status" in + 0|1) ;; + *) + cmux_ssh_auth_dynamic_discovery_abort + return 1 + ;; + esac + if /usr/bin/awk \ + -v cmux_marker_device="$cmux_ssh_auth_marker_device" \ + -v cmux_marker_device_hex="$cmux_ssh_auth_marker_device_hex" \ + -v cmux_marker_inode="$cmux_ssh_auth_marker_inode" ' + function parse_error() { cmux_parse_error = 1 } + /^p/ { + if ($0 !~ /^p[0-9]+$/) parse_error() + else { + cmux_lsof_pid = substr($0, 2) + cmux_lsof_fd = "" + cmux_lsof_device = "" + } + next + } + /^f/ { + if ($0 !~ /^f.+$/) parse_error() + else { + cmux_lsof_fd = substr($0, 2) + cmux_lsof_device = "" + } + next + } + /^D/ { + if ($0 !~ /^D.+$/) parse_error() + else cmux_lsof_device = substr($0, 2) + next + } + /^i/ { + if ($0 !~ /^i[0-9]+$/ || cmux_lsof_pid !~ /^[0-9]+$/ || + cmux_lsof_fd !~ /^7/) { + parse_error() + } else if (cmux_lsof_device != "" && + (cmux_lsof_device == cmux_marker_device || + cmux_lsof_device == cmux_marker_device_hex) && + substr($0, 2) == cmux_marker_inode) { + print cmux_lsof_pid + } + next + } + { parse_error() } + END { exit cmux_parse_error ? 1 : 0 } + ' "$cmux_ssh_auth_marker_lsof_output" > "$cmux_ssh_auth_marker_holders"; then + : + else + cmux_ssh_auth_dynamic_discovery_abort + return 1 fi fi - /usr/bin/awk ' + if ! /usr/bin/awk ' FILENAME == ARGV[1] { # PPID is lineage metadata and can change when a TERM handler # outlives its parent. PID, PGID, and the kernel birth token @@ -698,7 +765,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { } } ' "$cmux_ssh_auth_members" "$cmux_ssh_auth_marker_holders" \ - "$cmux_ssh_auth_snapshot" >> "$cmux_ssh_auth_dynamic_members" + "$cmux_ssh_auth_snapshot" >> "$cmux_ssh_auth_dynamic_members"; then + cmux_ssh_auth_dynamic_discovery_abort + return 1 + fi + return 0 } # The generated authentication wrapper publishes the nonce only after @@ -717,51 +788,32 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { if [ ! -p "$cmux_ssh_auth_term_event_ack_fifo" ] || ! exec 8<> "$cmux_ssh_auth_term_event_ack_fifo"; then return 0; fi exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 cmux_ssh_auth_term_event_writer= - if [ "$cmux_ssh_auth_signal_backend" != darwin ]; then - # POSIX sh has no timed-read primitive. Use one bounded select - # call when Perl is available, and fail open when it is not. - cmux_ssh_auth_perl_command=$(command -v perl 2>/dev/null || true) - if [ -n "$cmux_ssh_auth_perl_command" ]; then - cmux_ssh_auth_term_event_writer=$( - "$cmux_ssh_auth_perl_command" -MIO::Select -e ' - use strict; - use warnings; - use Fcntl qw(O_RDWR O_NONBLOCK); - my ($path, $timeout) = @ARGV; - sysopen(my $fifo, $path, O_RDWR | O_NONBLOCK) or exit 1; - my $select = IO::Select->new($fifo); - if ($select->can_read($timeout)) { - my $line = <$fifo>; - print $line if defined $line; - } - ' "$cmux_ssh_auth_term_event_fifo" 5 2>/dev/null || true - ) - fi - if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_event_token" ]; then - cmux_ssh_auth_term_event_received=1 - else - exec 9>&- - fi - return 0 + # POSIX sh has no portable timed FIFO read. Use one Perl select + # with the remaining monotonic budget, so process startup and the + # wait itself cannot extend the shared cleanup deadline. The + # helper already requires Perl for every process-table snapshot. + if [ -n "$cmux_ssh_auth_perl_command" ] && + cmux_ssh_auth_get_remaining_millis; then + cmux_ssh_auth_term_event_writer=$( + "$cmux_ssh_auth_perl_command" -MIO::Select -e ' + use strict; + use warnings; + use Fcntl qw(O_RDWR O_NONBLOCK); + my ($path, $timeout_millis) = @ARGV; + exit 0 unless defined $timeout_millis && $timeout_millis =~ /\A[1-9][0-9]*\z/; + sysopen(my $fifo, $path, O_RDWR | O_NONBLOCK) or exit 1; + my $select = IO::Select->new($fifo); + if ($select->can_read($timeout_millis / 1000)) { + my $line = <$fifo>; + print $line if defined $line; + } + ' "$cmux_ssh_auth_term_event_fifo" \ + "$cmux_ssh_auth_remaining_millis" 2>/dev/null || true + ) fi - # macOS /bin/sh accepts only an integer read timeout. Five bounded - # reads give scheduler pressure time to deliver the handler without - # relying on a non-POSIX shell timer. The FIFO stays open across - # retries. - cmux_ssh_auth_term_event_wait_attempt=0 - while [ "$cmux_ssh_auth_term_event_received" != 1 ] && - [ "$cmux_ssh_auth_term_event_wait_attempt" -lt 5 ]; do - if IFS= read -r -t 1 cmux_ssh_auth_term_event_writer <&9; then - # The FIFO directory and payload both carry the random, - # per-attempt nonce. Process ownership is established by the - # marker FD journal, not by a PID that can be reused. - if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_event_token" ]; then - cmux_ssh_auth_term_event_received=1 - fi - fi - cmux_ssh_auth_term_event_wait_attempt=$((cmux_ssh_auth_term_event_wait_attempt + 1)) - done - if [ "$cmux_ssh_auth_term_event_received" != 1 ]; then + if [ "$cmux_ssh_auth_term_event_writer" = "$cmux_ssh_auth_event_token" ]; then + cmux_ssh_auth_term_event_received=1 + else exec 9>&- fi } @@ -1160,6 +1212,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_ssh_auth_kill_candidates" \ "$cmux_ssh_auth_root_identity_file" "$cmux_ssh_auth_root_identity_candidate" \ "$cmux_ssh_auth_dynamic_members" "$cmux_ssh_auth_marker_holders" \ + "$cmux_ssh_auth_marker_lsof_output" \ 2>/dev/null || true /bin/rmdir "$cmux_ssh_auth_state_dir" 2>/dev/null || true } @@ -1266,6 +1319,12 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # identity journal remains valid after reparenting. cmux_ssh_auth_record_dynamic_members || true cmux_ssh_auth_ack_term_event + # Missing marker proof is a cleanup failure. Let the EXIT trap resume + # verified stops and terminate only the identity-fenced root; never + # let an empty dynamic journal declare cleanup complete. + if [ "$cmux_ssh_auth_dynamic_discovery_failed" = 1 ]; then + exit 0 + fi # Rebuild ownership from exact identities and descendants. Marker-FD # identities catch a replacement that outlives its parent without @@ -1493,7 +1552,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { ''|*[!0-9:]*|:*|*:) false ;; *) true ;; esac && [ -n "$cmux_ssh_auth_marker_device_hex" ] && - ( set -C; printf '%s %s %s\n' "$cmux_ssh_auth_marker_device_hex" "$cmux_ssh_auth_marker_device" "$cmux_ssh_auth_marker_inode" > "$cmux_ssh_auth_marker_identity_path" ) 2>/dev/null; then + ( set -C; printf '%s %s %s\\n' "$cmux_ssh_auth_marker_device_hex" "$cmux_ssh_auth_marker_device" "$cmux_ssh_auth_marker_inode" > "$cmux_ssh_auth_marker_identity_path" ) 2>/dev/null; then cmux_ssh_auth_marker_owned=1 else exec 7>&- From eca300e767d5bfd942b2a3443b4d275771975269 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:42:42 -0700 Subject: [PATCH 44/61] fix: journal pre-stopped SSH auth processes --- ...HForegroundAuthenticationRetryPolicy.swift | 41 +++++++++-- ...groundAuthenticationRetryPolicyTests.swift | 72 +++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index a1d39544ac39..50afa7452a54 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -154,7 +154,15 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { status = uint32.call(4) seconds = uint64.call(120) microseconds = uint64.call(128) - version = uint32.call(168) + # `proc_uniqidentifierinfo` starts after the 136-byte + # `proc_bsdinfo`. Its UUID is 16 bytes, followed by the + # process and parent unique IDs (8 bytes each), then the + # 32-bit `p_idversion` used by audit-token signaling. + # Keep the offset derived from that layout so a change to + # either record cannot silently point at reserved bytes. + proc_bsdinfo_size = 136 + proc_uniqidentifierinfo_pidversion_offset = proc_bsdinfo_size + 16 + 8 + 8 + version = uint32.call(proc_uniqidentifierinfo_pidversion_offset) exit 1 unless observed_pid == pid && parent == expected_parent && group > 0 && status != 5 && seconds > 0 && microseconds < 1_000_000 && version > 0 @@ -222,9 +230,12 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { bytes = buffer.to_s(size) uint32 = ->(offset) { bytes.byteslice(offset, 4).unpack1("L<") } uint64 = ->(offset) { bytes.byteslice(offset, 8).unpack1("Q<") } + proc_bsdinfo_size = 136 + proc_uniqidentifierinfo_pidversion_offset = proc_bsdinfo_size + 16 + 8 + 8 observed = [ uint32.call(12), uint32.call(16), uint32.call(100), uint32.call(4), - uint64.call(120), uint64.call(128), uint32.call(168) + uint64.call(120), uint64.call(128), + uint32.call(proc_uniqidentifierinfo_pidversion_offset) ] expected = [pid, parent, group, nil, seconds, microseconds, version] exit 0 unless observed[0] == expected[0] && observed[1] == expected[1] && @@ -909,7 +920,17 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { my $pid_number = int($pid); my $identity = read_identity($pid); next unless matches($identity, $parent, $group, $expected_start); - next if $signal_name eq "STOP" && $identity->[0] =~ /T/; + if ($signal_name eq "STOP" && $identity->[0] =~ /T/) { + # A process that was already stopped in the candidate + # snapshot still belongs in the ownership journal. Do not + # send another STOP, and retain its original T state so a + # rollback will not resume it. A process that became stopped + # after the snapshot is not ours, so leave it unclaimed. + if ($original_state eq "T") { + print {$output} "$line\n" or $failed = 1; + } + next; + } next if $signal_name eq "CONT" && $original_state =~ /T/; next if $signal_name =~ /\A(?:TERM|KILL)\z/ && $identity->[0] !~ /T/; next if $require_stopped eq "1" && $identity->[0] !~ /T/ && @@ -1005,6 +1026,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { process_info_size = 192 uint32 = ->(bytes, offset) { bytes.byteslice(offset, 4).unpack1("L<") } uint64 = ->(bytes, offset) { bytes.byteslice(offset, 8).unpack1("Q<") } + proc_bsdinfo_size = 136 + proc_uniqidentifierinfo_pidversion_offset = proc_bsdinfo_size + 16 + 8 + 8 process_identity = lambda do |pid| buffer = Fiddle::Pointer.malloc(process_info_size) written = CmuxLibproc.proc_pidinfo( @@ -1019,7 +1042,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { uint32.call(bytes, 4), # pbi_status uint64.call(bytes, 120), # pbi_start_tvsec uint64.call(bytes, 128), # pbi_start_tvusec - uint32.call(bytes, 168), # proc_uniqueidentifierinfo.id_version + uint32.call(bytes, proc_uniqidentifierinfo_pidversion_offset), # p_idversion ] rescue ArgumentError, Fiddle::DLError, NoMethodError, RangeError, TypeError nil @@ -1058,9 +1081,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { next if before[3] == 5 if signal_name == "STOP" - # Do not claim a process that was already stopped by another - # owner. Resuming it would be an observable side effect. - next if before[3] == 4 + if before[3] == 4 + # Preserve a process that was already stopped in the + # candidate snapshot without sending another STOP. The + # original T state prevents rollback from resuming it. + output.puts(line.chomp) if original_state == "T" + next + end next unless signal_exact.call(before, signals[signal_name]) # SIGSTOP delivery can be asynchronous to proc_pidinfo. The # audit-token call already verified this exact identity, so diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 13db97c488c6..51fe4ef5e142 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -301,6 +301,78 @@ struct SSHForegroundAuthenticationRetryPolicyTests { #expect(processLiveness(leafPID) != nil) } + @Test func terminatesTreeWhenAuthenticationRootWasAlreadyStopped() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ssh-auth-prestopped-\(UUID().uuidString)", isDirectory: true) + let leafScript = root.appendingPathComponent("leaf.sh") + let leafPIDFile = root.appendingPathComponent("leaf.pid") + let readyMarker = root.appendingPathComponent("ready") + let signalLog = root.appendingPathComponent("signal.log") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try """ + #!/bin/sh + trap '' HUP INT + trap 'printf "%s\\n" term > "$CMUX_TEST_SIGNAL_LOG"' TERM + printf '%s\\n' "$$" > "$CMUX_TEST_LEAF_PID" + : > "$CMUX_TEST_READY_MARKER" + while :; do /bin/sleep 30; done + """.write(to: leafScript, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: leafScript.path) + + let command = """ + \(SSHForegroundAuthenticationRetryPolicy().processTreeTerminationShellFunction()) + ( /bin/sh "$CMUX_TEST_LEAF_SCRIPT" & wait $! ) & + cmux_test_auth_root=$! + trap '/bin/kill -CONT "$cmux_test_auth_root" >/dev/null 2>&1 || true; /bin/kill -KILL "$cmux_test_auth_root" >/dev/null 2>&1 || true' EXIT + cmux_test_ready_attempt=0 + while [ ! -f "$CMUX_TEST_READY_MARKER" ] && [ "$cmux_test_ready_attempt" -lt 300 ]; do + /bin/sleep 0.01 + cmux_test_ready_attempt=$((cmux_test_ready_attempt + 1)) + done + test -f "$CMUX_TEST_READY_MARKER" || exit 98 + # The root is already stopped before cleanup takes ownership. The + # helper must journal it without sending a second STOP, then terminate + # both the root and its foreground-auth child. + /bin/kill -STOP "$cmux_test_auth_root" + cmux_ssh_terminate_auth_process_tree "$cmux_test_auth_root" "$$" + wait "$cmux_test_auth_root" 2>/dev/null || true + test "$(/bin/cat "$CMUX_TEST_SIGNAL_LOG" 2>/dev/null || true)" = term + trap - EXIT + """ + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + process.environment = ProcessInfo.processInfo.environment.merging([ + "CMUX_TEST_LEAF_SCRIPT": leafScript.path, + "CMUX_TEST_LEAF_PID": leafPIDFile.path, + "CMUX_TEST_READY_MARKER": readyMarker.path, + "CMUX_TEST_SIGNAL_LOG": signalLog.path, + ]) { _, override in override } + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + let stderrCapture = try makeStandardErrorCapture() + defer { removeStandardErrorCapture(stderrCapture) } + process.standardError = stderrCapture.handle + + try process.run() + try waitForExit(process, stderrCapture: stderrCapture) + + let leafPID = try #require(Int32( + String(contentsOf: leafPIDFile, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + )) + defer { Darwin.kill(leafPID, SIGKILL) } + waitForProcessesToExit([leafPID]) + + #expect(process.terminationStatus == 0) + #expect(try String(contentsOf: signalLog, encoding: .utf8) == "term\n") + #expect(processLiveness(leafPID) == false) + #expect(processLiveness(leafPID) != nil) + } + @Test func refusesAuthenticationRootWithMismatchedKnownParent() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory From 72b00b4c3ef66ede8dd53ff89fea2e4e277c3bf3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:55:57 -0700 Subject: [PATCH 45/61] fix: accept legacy Darwin process info size --- ...HForegroundAuthenticationRetryPolicy.swift | 53 +++++++++++++------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 50afa7452a54..aafea908cd58 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -138,14 +138,21 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { end pid = Integer(ARGV[0]) expected_parent = Integer(ARGV[1]) - size = 192 + proc_bsdinfo_size = 136 + proc_uniqidentifierinfo_legacy_size = 40 + proc_uniqidentifierinfo_current_size = 56 + legacy_size = proc_bsdinfo_size + proc_uniqidentifierinfo_legacy_size + size = proc_bsdinfo_size + proc_uniqidentifierinfo_current_size + accepted_sizes = [legacy_size, size] buffer = Fiddle::Pointer.malloc(size) # XNU names flavor 18 PROC_PIDT_BSDINFOWITHUNIQID. It # returns proc_bsdinfo (136 bytes) followed by - # proc_uniqidentifierinfo (56 bytes), for 192 bytes. + # proc_uniqidentifierinfo (56 bytes), for 192 bytes. A + # legacy kernel may report a 176-byte record instead. Use + # only the common fields and reject every other size. written = CmuxLibproc.proc_pidinfo(pid, 18, 0, buffer, size) - exit 1 unless written == size - bytes = buffer.to_s(size) + exit 1 unless accepted_sizes.include?(written) + bytes = buffer.to_s(written) uint32 = ->(offset) { bytes.byteslice(offset, 4).unpack1("L<") } uint64 = ->(offset) { bytes.byteslice(offset, 8).unpack1("Q<") } observed_pid = uint32.call(12) @@ -154,14 +161,14 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { status = uint32.call(4) seconds = uint64.call(120) microseconds = uint64.call(128) - # `proc_uniqidentifierinfo` starts after the 136-byte - # `proc_bsdinfo`. Its UUID is 16 bytes, followed by the + # `proc_uniqidentifierinfo` starts after the + # `proc_bsdinfo` record. Its UUID is 16 bytes, followed by the # process and parent unique IDs (8 bytes each), then the # 32-bit `p_idversion` used by audit-token signaling. # Keep the offset derived from that layout so a change to # either record cannot silently point at reserved bytes. - proc_bsdinfo_size = 136 proc_uniqidentifierinfo_pidversion_offset = proc_bsdinfo_size + 16 + 8 + 8 + exit 1 unless bytes.bytesize >= proc_uniqidentifierinfo_pidversion_offset + 4 version = uint32.call(proc_uniqidentifierinfo_pidversion_offset) exit 1 unless observed_pid == pid && parent == expected_parent && group > 0 && status != 5 && seconds > 0 && @@ -223,15 +230,20 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { extern "int proc_pidinfo(int, int, unsigned long long, void*, int)" extern "int proc_signal_with_audittoken(void*, int)" end - size = 192 + proc_bsdinfo_size = 136 + proc_uniqidentifierinfo_legacy_size = 40 + proc_uniqidentifierinfo_current_size = 56 + legacy_size = proc_bsdinfo_size + proc_uniqidentifierinfo_legacy_size + size = proc_bsdinfo_size + proc_uniqidentifierinfo_current_size + accepted_sizes = [legacy_size, size] buffer = Fiddle::Pointer.malloc(size) written = CmuxLibproc.proc_pidinfo(pid, 18, 0, buffer, size) - exit 0 unless written == size - bytes = buffer.to_s(size) + exit 0 unless accepted_sizes.include?(written) + bytes = buffer.to_s(written) uint32 = ->(offset) { bytes.byteslice(offset, 4).unpack1("L<") } uint64 = ->(offset) { bytes.byteslice(offset, 8).unpack1("Q<") } - proc_bsdinfo_size = 136 proc_uniqidentifierinfo_pidversion_offset = proc_bsdinfo_size + 16 + 8 + 8 + exit 0 unless bytes.bytesize >= proc_uniqidentifierinfo_pidversion_offset + 4 observed = [ uint32.call(12), uint32.call(16), uint32.call(100), uint32.call(4), uint64.call(120), uint64.call(128), @@ -1021,20 +1033,27 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { end bsd_with_unique_id_flavor = 18 - # This flavor returns proc_bsdinfo (136 bytes) followed by - # proc_uniqidentifierinfo (56 bytes), for a 192-byte record. - process_info_size = 192 + # Current XNU returns proc_bsdinfo (136 bytes) followed by + # proc_uniqidentifierinfo (56 bytes), for a 192-byte record. A + # legacy kernel may report 176 bytes. Accept only those two + # ABI sizes, and fail closed for any other record. + proc_bsdinfo_size = 136 + proc_uniqidentifierinfo_legacy_size = 40 + proc_uniqidentifierinfo_current_size = 56 + legacy_process_info_size = proc_bsdinfo_size + proc_uniqidentifierinfo_legacy_size + process_info_size = proc_bsdinfo_size + proc_uniqidentifierinfo_current_size + accepted_process_info_sizes = [legacy_process_info_size, process_info_size] uint32 = ->(bytes, offset) { bytes.byteslice(offset, 4).unpack1("L<") } uint64 = ->(bytes, offset) { bytes.byteslice(offset, 8).unpack1("Q<") } - proc_bsdinfo_size = 136 proc_uniqidentifierinfo_pidversion_offset = proc_bsdinfo_size + 16 + 8 + 8 process_identity = lambda do |pid| buffer = Fiddle::Pointer.malloc(process_info_size) written = CmuxLibproc.proc_pidinfo( Integer(pid), bsd_with_unique_id_flavor, 0, buffer, process_info_size ) - next nil unless written == process_info_size - bytes = buffer.to_s(process_info_size) + next nil unless accepted_process_info_sizes.include?(written) + bytes = buffer.to_s(written) + next nil unless bytes.bytesize >= proc_uniqidentifierinfo_pidversion_offset + 4 [ uint32.call(bytes, 12), # pbi_pid uint32.call(bytes, 16), # pbi_ppid From 528c078e198ee4d8cab65a77dd0134d302a4e84c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:03:26 -0700 Subject: [PATCH 46/61] fix: use separate Darwin process identity records --- ...HForegroundAuthenticationRetryPolicy.swift | 157 ++++++++++-------- 1 file changed, 86 insertions(+), 71 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index aafea908cd58..9dd40482f128 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -138,38 +138,40 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { end pid = Integer(ARGV[0]) expected_parent = Integer(ARGV[1]) + # Read the documented component flavors separately. Flavor + # 3 is PROC_PIDTBSDINFO (136 bytes), and flavor 17 is + # PROC_PIDUNIQIDENTIFIERINFO (56 bytes). This avoids making + # cleanup depend on the private combined flavor record + # size while retaining the kernel start time and pid version. + bsd_flavor = 3 + uniqidentifier_flavor = 17 proc_bsdinfo_size = 136 - proc_uniqidentifierinfo_legacy_size = 40 - proc_uniqidentifierinfo_current_size = 56 - legacy_size = proc_bsdinfo_size + proc_uniqidentifierinfo_legacy_size - size = proc_bsdinfo_size + proc_uniqidentifierinfo_current_size - accepted_sizes = [legacy_size, size] - buffer = Fiddle::Pointer.malloc(size) - # XNU names flavor 18 PROC_PIDT_BSDINFOWITHUNIQID. It - # returns proc_bsdinfo (136 bytes) followed by - # proc_uniqidentifierinfo (56 bytes), for 192 bytes. A - # legacy kernel may report a 176-byte record instead. Use - # only the common fields and reject every other size. - written = CmuxLibproc.proc_pidinfo(pid, 18, 0, buffer, size) - exit 1 unless accepted_sizes.include?(written) - bytes = buffer.to_s(written) - uint32 = ->(offset) { bytes.byteslice(offset, 4).unpack1("L<") } - uint64 = ->(offset) { bytes.byteslice(offset, 8).unpack1("Q<") } - observed_pid = uint32.call(12) - parent = uint32.call(16) - group = uint32.call(100) - status = uint32.call(4) - seconds = uint64.call(120) - microseconds = uint64.call(128) - # `proc_uniqidentifierinfo` starts after the - # `proc_bsdinfo` record. Its UUID is 16 bytes, followed by the - # process and parent unique IDs (8 bytes each), then the - # 32-bit `p_idversion` used by audit-token signaling. - # Keep the offset derived from that layout so a change to - # either record cannot silently point at reserved bytes. - proc_uniqidentifierinfo_pidversion_offset = proc_bsdinfo_size + 16 + 8 + 8 - exit 1 unless bytes.bytesize >= proc_uniqidentifierinfo_pidversion_offset + 4 - version = uint32.call(proc_uniqidentifierinfo_pidversion_offset) + proc_uniqidentifierinfo_size = 56 + bsd_buffer = Fiddle::Pointer.malloc(proc_bsdinfo_size) + uniqidentifier_buffer = Fiddle::Pointer.malloc(proc_uniqidentifierinfo_size) + bsd_written = CmuxLibproc.proc_pidinfo( + pid, bsd_flavor, 0, bsd_buffer, proc_bsdinfo_size + ) + uniqidentifier_written = CmuxLibproc.proc_pidinfo( + pid, uniqidentifier_flavor, 0, uniqidentifier_buffer, + proc_uniqidentifierinfo_size + ) + exit 1 unless bsd_written == proc_bsdinfo_size && + uniqidentifier_written == proc_uniqidentifierinfo_size + bsd_bytes = bsd_buffer.to_s(bsd_written) + uniqidentifier_bytes = uniqidentifier_buffer.to_s(uniqidentifier_written) + uint32 = ->(bytes, offset) { bytes.byteslice(offset, 4).unpack1("L<") } + uint64 = ->(bytes, offset) { bytes.byteslice(offset, 8).unpack1("Q<") } + observed_pid = uint32.call(bsd_bytes, 12) + parent = uint32.call(bsd_bytes, 16) + group = uint32.call(bsd_bytes, 100) + status = uint32.call(bsd_bytes, 4) + seconds = uint64.call(bsd_bytes, 120) + microseconds = uint64.call(bsd_bytes, 128) + # `proc_uniqidentifierinfo` stores its UUID first, then the + # process and parent unique IDs, followed by `p_idversion`. + proc_uniqidentifierinfo_pidversion_offset = 16 + 8 + 8 + version = uint32.call(uniqidentifier_bytes, proc_uniqidentifierinfo_pidversion_offset) exit 1 unless observed_pid == pid && parent == expected_parent && group > 0 && status != 5 && seconds > 0 && microseconds < 1_000_000 && version > 0 @@ -230,24 +232,36 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { extern "int proc_pidinfo(int, int, unsigned long long, void*, int)" extern "int proc_signal_with_audittoken(void*, int)" end + # Read the documented component flavors separately. Flavor + # 3 is PROC_PIDTBSDINFO (136 bytes), and flavor 17 is + # PROC_PIDUNIQIDENTIFIERINFO (56 bytes). Keep the validation + # symmetric with root capture and avoid the combined flavor + # private record-size assumptions. + bsd_flavor = 3 + uniqidentifier_flavor = 17 proc_bsdinfo_size = 136 - proc_uniqidentifierinfo_legacy_size = 40 - proc_uniqidentifierinfo_current_size = 56 - legacy_size = proc_bsdinfo_size + proc_uniqidentifierinfo_legacy_size - size = proc_bsdinfo_size + proc_uniqidentifierinfo_current_size - accepted_sizes = [legacy_size, size] - buffer = Fiddle::Pointer.malloc(size) - written = CmuxLibproc.proc_pidinfo(pid, 18, 0, buffer, size) - exit 0 unless accepted_sizes.include?(written) - bytes = buffer.to_s(written) - uint32 = ->(offset) { bytes.byteslice(offset, 4).unpack1("L<") } - uint64 = ->(offset) { bytes.byteslice(offset, 8).unpack1("Q<") } - proc_uniqidentifierinfo_pidversion_offset = proc_bsdinfo_size + 16 + 8 + 8 - exit 0 unless bytes.bytesize >= proc_uniqidentifierinfo_pidversion_offset + 4 + proc_uniqidentifierinfo_size = 56 + proc_uniqidentifierinfo_pidversion_offset = 16 + 8 + 8 + bsd_buffer = Fiddle::Pointer.malloc(proc_bsdinfo_size) + uniqidentifier_buffer = Fiddle::Pointer.malloc(proc_uniqidentifierinfo_size) + bsd_written = CmuxLibproc.proc_pidinfo( + pid, bsd_flavor, 0, bsd_buffer, proc_bsdinfo_size + ) + uniqidentifier_written = CmuxLibproc.proc_pidinfo( + pid, uniqidentifier_flavor, 0, uniqidentifier_buffer, + proc_uniqidentifierinfo_size + ) + exit 0 unless bsd_written == proc_bsdinfo_size && + uniqidentifier_written == proc_uniqidentifierinfo_size + bsd_bytes = bsd_buffer.to_s(bsd_written) + uniqidentifier_bytes = uniqidentifier_buffer.to_s(uniqidentifier_written) + uint32 = ->(bytes, offset) { bytes.byteslice(offset, 4).unpack1("L<") } + uint64 = ->(bytes, offset) { bytes.byteslice(offset, 8).unpack1("Q<") } observed = [ - uint32.call(12), uint32.call(16), uint32.call(100), uint32.call(4), - uint64.call(120), uint64.call(128), - uint32.call(proc_uniqidentifierinfo_pidversion_offset) + uint32.call(bsd_bytes, 12), uint32.call(bsd_bytes, 16), + uint32.call(bsd_bytes, 100), uint32.call(bsd_bytes, 4), + uint64.call(bsd_bytes, 120), uint64.call(bsd_bytes, 128), + uint32.call(uniqidentifier_bytes, proc_uniqidentifierinfo_pidversion_offset) ] expected = [pid, parent, group, nil, seconds, microseconds, version] exit 0 unless observed[0] == expected[0] && observed[1] == expected[1] && @@ -1032,36 +1046,37 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { extern "int proc_signal_with_audittoken(void*, int)" end - bsd_with_unique_id_flavor = 18 - # Current XNU returns proc_bsdinfo (136 bytes) followed by - # proc_uniqidentifierinfo (56 bytes), for a 192-byte record. A - # legacy kernel may report 176 bytes. Accept only those two - # ABI sizes, and fail closed for any other record. + bsd_flavor = 3 + uniqidentifier_flavor = 17 proc_bsdinfo_size = 136 - proc_uniqidentifierinfo_legacy_size = 40 - proc_uniqidentifierinfo_current_size = 56 - legacy_process_info_size = proc_bsdinfo_size + proc_uniqidentifierinfo_legacy_size - process_info_size = proc_bsdinfo_size + proc_uniqidentifierinfo_current_size - accepted_process_info_sizes = [legacy_process_info_size, process_info_size] + proc_uniqidentifierinfo_size = 56 uint32 = ->(bytes, offset) { bytes.byteslice(offset, 4).unpack1("L<") } uint64 = ->(bytes, offset) { bytes.byteslice(offset, 8).unpack1("Q<") } - proc_uniqidentifierinfo_pidversion_offset = proc_bsdinfo_size + 16 + 8 + 8 + # `proc_uniqidentifierinfo` stores its UUID first, then the + # process and parent unique IDs, followed by `p_idversion`. + proc_uniqidentifierinfo_pidversion_offset = 16 + 8 + 8 process_identity = lambda do |pid| - buffer = Fiddle::Pointer.malloc(process_info_size) - written = CmuxLibproc.proc_pidinfo( - Integer(pid), bsd_with_unique_id_flavor, 0, buffer, process_info_size + bsd_buffer = Fiddle::Pointer.malloc(proc_bsdinfo_size) + uniqidentifier_buffer = Fiddle::Pointer.malloc(proc_uniqidentifierinfo_size) + bsd_written = CmuxLibproc.proc_pidinfo( + Integer(pid), bsd_flavor, 0, bsd_buffer, proc_bsdinfo_size ) - next nil unless accepted_process_info_sizes.include?(written) - bytes = buffer.to_s(written) - next nil unless bytes.bytesize >= proc_uniqidentifierinfo_pidversion_offset + 4 + uniqidentifier_written = CmuxLibproc.proc_pidinfo( + Integer(pid), uniqidentifier_flavor, 0, uniqidentifier_buffer, + proc_uniqidentifierinfo_size + ) + next nil unless bsd_written == proc_bsdinfo_size && + uniqidentifier_written == proc_uniqidentifierinfo_size + bsd_bytes = bsd_buffer.to_s(bsd_written) + uniqidentifier_bytes = uniqidentifier_buffer.to_s(uniqidentifier_written) [ - uint32.call(bytes, 12), # pbi_pid - uint32.call(bytes, 16), # pbi_ppid - uint32.call(bytes, 100), # pbi_pgid - uint32.call(bytes, 4), # pbi_status - uint64.call(bytes, 120), # pbi_start_tvsec - uint64.call(bytes, 128), # pbi_start_tvusec - uint32.call(bytes, proc_uniqidentifierinfo_pidversion_offset), # p_idversion + uint32.call(bsd_bytes, 12), # pbi_pid + uint32.call(bsd_bytes, 16), # pbi_ppid + uint32.call(bsd_bytes, 100), # pbi_pgid + uint32.call(bsd_bytes, 4), # pbi_status + uint64.call(bsd_bytes, 120), # pbi_start_tvsec + uint64.call(bsd_bytes, 128), # pbi_start_tvusec + uint32.call(uniqidentifier_bytes, proc_uniqidentifierinfo_pidversion_offset), # p_idversion ] rescue ArgumentError, Fiddle::DLError, NoMethodError, RangeError, TypeError nil From ce63577e2794271f4823de09a274279e48629f25 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:12:17 -0700 Subject: [PATCH 47/61] fix: derive Darwin process record offsets --- ...HForegroundAuthenticationRetryPolicy.swift | 95 ++++++++++++++----- 1 file changed, 69 insertions(+), 26 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 9dd40482f128..d361be8e0c2b 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -145,8 +145,23 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # size while retaining the kernel start time and pid version. bsd_flavor = 3 uniqidentifier_flavor = 17 - proc_bsdinfo_size = 136 - proc_uniqidentifierinfo_size = 56 + proc_bsdinfo_scalar_size = 4 + proc_bsdinfo_status_offset = proc_bsdinfo_scalar_size + proc_bsdinfo_pid_offset = proc_bsdinfo_scalar_size * 3 + proc_bsdinfo_ppid_offset = proc_bsdinfo_pid_offset + proc_bsdinfo_scalar_size + proc_bsdinfo_comm_offset = proc_bsdinfo_scalar_size * 12 + proc_bsdinfo_name_offset = proc_bsdinfo_comm_offset + 16 + proc_bsdinfo_nfiles_offset = proc_bsdinfo_name_offset + 32 + proc_bsdinfo_pgid_offset = proc_bsdinfo_nfiles_offset + proc_bsdinfo_scalar_size + proc_bsdinfo_start_tvsec_offset = proc_bsdinfo_pgid_offset + proc_bsdinfo_scalar_size * 5 + proc_bsdinfo_start_tvusec_offset = proc_bsdinfo_start_tvsec_offset + 8 + proc_bsdinfo_size = proc_bsdinfo_start_tvusec_offset + 8 + proc_uniqidentifierinfo_uuid_size = 16 + proc_uniqidentifierinfo_unique_id_size = 8 + proc_uniqidentifierinfo_pidversion_offset = + proc_uniqidentifierinfo_uuid_size + proc_uniqidentifierinfo_unique_id_size * 2 + proc_uniqidentifierinfo_size = + proc_uniqidentifierinfo_pidversion_offset + 4 + 4 + 8 + 8 bsd_buffer = Fiddle::Pointer.malloc(proc_bsdinfo_size) uniqidentifier_buffer = Fiddle::Pointer.malloc(proc_uniqidentifierinfo_size) bsd_written = CmuxLibproc.proc_pidinfo( @@ -162,15 +177,12 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { uniqidentifier_bytes = uniqidentifier_buffer.to_s(uniqidentifier_written) uint32 = ->(bytes, offset) { bytes.byteslice(offset, 4).unpack1("L<") } uint64 = ->(bytes, offset) { bytes.byteslice(offset, 8).unpack1("Q<") } - observed_pid = uint32.call(bsd_bytes, 12) - parent = uint32.call(bsd_bytes, 16) - group = uint32.call(bsd_bytes, 100) - status = uint32.call(bsd_bytes, 4) - seconds = uint64.call(bsd_bytes, 120) - microseconds = uint64.call(bsd_bytes, 128) - # `proc_uniqidentifierinfo` stores its UUID first, then the - # process and parent unique IDs, followed by `p_idversion`. - proc_uniqidentifierinfo_pidversion_offset = 16 + 8 + 8 + observed_pid = uint32.call(bsd_bytes, proc_bsdinfo_pid_offset) + parent = uint32.call(bsd_bytes, proc_bsdinfo_ppid_offset) + group = uint32.call(bsd_bytes, proc_bsdinfo_pgid_offset) + status = uint32.call(bsd_bytes, proc_bsdinfo_status_offset) + seconds = uint64.call(bsd_bytes, proc_bsdinfo_start_tvsec_offset) + microseconds = uint64.call(bsd_bytes, proc_bsdinfo_start_tvusec_offset) version = uint32.call(uniqidentifier_bytes, proc_uniqidentifierinfo_pidversion_offset) exit 1 unless observed_pid == pid && parent == expected_parent && group > 0 && status != 5 && seconds > 0 && @@ -239,9 +251,23 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # private record-size assumptions. bsd_flavor = 3 uniqidentifier_flavor = 17 - proc_bsdinfo_size = 136 - proc_uniqidentifierinfo_size = 56 - proc_uniqidentifierinfo_pidversion_offset = 16 + 8 + 8 + proc_bsdinfo_scalar_size = 4 + proc_bsdinfo_status_offset = proc_bsdinfo_scalar_size + proc_bsdinfo_pid_offset = proc_bsdinfo_scalar_size * 3 + proc_bsdinfo_ppid_offset = proc_bsdinfo_pid_offset + proc_bsdinfo_scalar_size + proc_bsdinfo_comm_offset = proc_bsdinfo_scalar_size * 12 + proc_bsdinfo_name_offset = proc_bsdinfo_comm_offset + 16 + proc_bsdinfo_nfiles_offset = proc_bsdinfo_name_offset + 32 + proc_bsdinfo_pgid_offset = proc_bsdinfo_nfiles_offset + proc_bsdinfo_scalar_size + proc_bsdinfo_start_tvsec_offset = proc_bsdinfo_pgid_offset + proc_bsdinfo_scalar_size * 5 + proc_bsdinfo_start_tvusec_offset = proc_bsdinfo_start_tvsec_offset + 8 + proc_bsdinfo_size = proc_bsdinfo_start_tvusec_offset + 8 + proc_uniqidentifierinfo_uuid_size = 16 + proc_uniqidentifierinfo_unique_id_size = 8 + proc_uniqidentifierinfo_pidversion_offset = + proc_uniqidentifierinfo_uuid_size + proc_uniqidentifierinfo_unique_id_size * 2 + proc_uniqidentifierinfo_size = + proc_uniqidentifierinfo_pidversion_offset + 4 + 4 + 8 + 8 bsd_buffer = Fiddle::Pointer.malloc(proc_bsdinfo_size) uniqidentifier_buffer = Fiddle::Pointer.malloc(proc_uniqidentifierinfo_size) bsd_written = CmuxLibproc.proc_pidinfo( @@ -258,9 +284,12 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { uint32 = ->(bytes, offset) { bytes.byteslice(offset, 4).unpack1("L<") } uint64 = ->(bytes, offset) { bytes.byteslice(offset, 8).unpack1("Q<") } observed = [ - uint32.call(bsd_bytes, 12), uint32.call(bsd_bytes, 16), - uint32.call(bsd_bytes, 100), uint32.call(bsd_bytes, 4), - uint64.call(bsd_bytes, 120), uint64.call(bsd_bytes, 128), + uint32.call(bsd_bytes, proc_bsdinfo_pid_offset), + uint32.call(bsd_bytes, proc_bsdinfo_ppid_offset), + uint32.call(bsd_bytes, proc_bsdinfo_pgid_offset), + uint32.call(bsd_bytes, proc_bsdinfo_status_offset), + uint64.call(bsd_bytes, proc_bsdinfo_start_tvsec_offset), + uint64.call(bsd_bytes, proc_bsdinfo_start_tvusec_offset), uint32.call(uniqidentifier_bytes, proc_uniqidentifierinfo_pidversion_offset) ] expected = [pid, parent, group, nil, seconds, microseconds, version] @@ -1048,13 +1077,27 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { bsd_flavor = 3 uniqidentifier_flavor = 17 - proc_bsdinfo_size = 136 - proc_uniqidentifierinfo_size = 56 + proc_bsdinfo_scalar_size = 4 + proc_bsdinfo_status_offset = proc_bsdinfo_scalar_size + proc_bsdinfo_pid_offset = proc_bsdinfo_scalar_size * 3 + proc_bsdinfo_ppid_offset = proc_bsdinfo_pid_offset + proc_bsdinfo_scalar_size + proc_bsdinfo_comm_offset = proc_bsdinfo_scalar_size * 12 + proc_bsdinfo_name_offset = proc_bsdinfo_comm_offset + 16 + proc_bsdinfo_nfiles_offset = proc_bsdinfo_name_offset + 32 + proc_bsdinfo_pgid_offset = proc_bsdinfo_nfiles_offset + proc_bsdinfo_scalar_size + proc_bsdinfo_start_tvsec_offset = proc_bsdinfo_pgid_offset + proc_bsdinfo_scalar_size * 5 + proc_bsdinfo_start_tvusec_offset = proc_bsdinfo_start_tvsec_offset + 8 + proc_bsdinfo_size = proc_bsdinfo_start_tvusec_offset + 8 + proc_uniqidentifierinfo_uuid_size = 16 + proc_uniqidentifierinfo_unique_id_size = 8 uint32 = ->(bytes, offset) { bytes.byteslice(offset, 4).unpack1("L<") } uint64 = ->(bytes, offset) { bytes.byteslice(offset, 8).unpack1("Q<") } # `proc_uniqidentifierinfo` stores its UUID first, then the # process and parent unique IDs, followed by `p_idversion`. - proc_uniqidentifierinfo_pidversion_offset = 16 + 8 + 8 + proc_uniqidentifierinfo_pidversion_offset = + proc_uniqidentifierinfo_uuid_size + proc_uniqidentifierinfo_unique_id_size * 2 + proc_uniqidentifierinfo_size = + proc_uniqidentifierinfo_pidversion_offset + 4 + 4 + 8 + 8 process_identity = lambda do |pid| bsd_buffer = Fiddle::Pointer.malloc(proc_bsdinfo_size) uniqidentifier_buffer = Fiddle::Pointer.malloc(proc_uniqidentifierinfo_size) @@ -1070,12 +1113,12 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { bsd_bytes = bsd_buffer.to_s(bsd_written) uniqidentifier_bytes = uniqidentifier_buffer.to_s(uniqidentifier_written) [ - uint32.call(bsd_bytes, 12), # pbi_pid - uint32.call(bsd_bytes, 16), # pbi_ppid - uint32.call(bsd_bytes, 100), # pbi_pgid - uint32.call(bsd_bytes, 4), # pbi_status - uint64.call(bsd_bytes, 120), # pbi_start_tvsec - uint64.call(bsd_bytes, 128), # pbi_start_tvusec + uint32.call(bsd_bytes, proc_bsdinfo_pid_offset), # pbi_pid + uint32.call(bsd_bytes, proc_bsdinfo_ppid_offset), # pbi_ppid + uint32.call(bsd_bytes, proc_bsdinfo_pgid_offset), # pbi_pgid + uint32.call(bsd_bytes, proc_bsdinfo_status_offset), # pbi_status + uint64.call(bsd_bytes, proc_bsdinfo_start_tvsec_offset), # pbi_start_tvsec + uint64.call(bsd_bytes, proc_bsdinfo_start_tvusec_offset), # pbi_start_tvusec uint32.call(uniqidentifier_bytes, proc_uniqidentifierinfo_pidversion_offset), # p_idversion ] rescue ArgumentError, Fiddle::DLError, NoMethodError, RangeError, TypeError From 34361273295b6a05e73ae08ebe27bb6c3db7d758 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:20:57 -0700 Subject: [PATCH 48/61] fix: fail closed on missing SSH marker proof --- ...HForegroundAuthenticationRetryPolicy.swift | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index d361be8e0c2b..ace71abdd9bb 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -724,7 +724,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { > "$cmux_ssh_auth_marker_lsof_output" 2>/dev/null cmux_ssh_auth_marker_lsof_status=$? case "$cmux_ssh_auth_marker_lsof_status" in - 0|1) ;; + 0) ;; *) cmux_ssh_auth_dynamic_discovery_abort return 1 @@ -777,6 +777,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_dynamic_discovery_abort return 1 fi + # A successful scan must prove at least one holder. An empty + # result can mean that lsof could not inspect the process table, + # so never let it silently authorize an empty lineage. + if [ ! -s "$cmux_ssh_auth_marker_holders" ]; then + cmux_ssh_auth_dynamic_discovery_abort + return 1 + fi fi if ! /usr/bin/awk ' FILENAME == ARGV[1] { @@ -845,14 +852,40 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # the wrapper alive during the post-TERM process-table snapshot. A # bounded read keeps plain fixtures and failed wrappers from blocking # cleanup. + cmux_ssh_auth_wait_for_term_grace() { + cmux_ssh_auth_get_remaining_millis || return 0 + [ -n "$cmux_ssh_auth_perl_command" ] || return 0 + "$cmux_ssh_auth_perl_command" -e ' + use strict; + use warnings; + my ($timeout_millis) = @ARGV; + exit 0 unless defined $timeout_millis && $timeout_millis =~ /\A[1-9][0-9]*\z/; + select undef, undef, undef, $timeout_millis / 1000; + ' "$cmux_ssh_auth_remaining_millis" >/dev/null 2>&1 || true + } + cmux_ssh_auth_wait_for_term_event() { - [ "$cmux_ssh_auth_wait_for_term_event_enabled" = 1 ] || return 0 - [ -n "$cmux_ssh_auth_event_token" ] || return 0 - if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then return 0; fi + if [ "$cmux_ssh_auth_wait_for_term_event_enabled" != 1 ] || + [ -z "$cmux_ssh_auth_event_token" ]; then + cmux_ssh_auth_wait_for_term_grace + return 0 + fi + if [ ! -p "$cmux_ssh_auth_term_event_fifo" ]; then + cmux_ssh_auth_wait_for_term_grace + return 0 + fi # Keep both descriptors below 10 because POSIX sh does not # require multi-digit redirection operands. - if [ ! -p "$cmux_ssh_auth_term_event_ack_fifo" ] || ! exec 8<> "$cmux_ssh_auth_term_event_ack_fifo"; then return 0; fi - exec 9<> "$cmux_ssh_auth_term_event_fifo" || return 0 + if [ ! -p "$cmux_ssh_auth_term_event_ack_fifo" ] || + ! exec 8<> "$cmux_ssh_auth_term_event_ack_fifo"; then + cmux_ssh_auth_wait_for_term_grace + return 0 + fi + if ! exec 9<> "$cmux_ssh_auth_term_event_fifo"; then + exec 8>&- + cmux_ssh_auth_wait_for_term_grace + return 0 + fi cmux_ssh_auth_term_event_writer= # POSIX sh has no portable timed FIFO read. Use one Perl select # with the remaining monotonic budget, so process startup and the @@ -881,6 +914,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_term_event_received=1 else exec 9>&- + cmux_ssh_auth_wait_for_term_grace fi } From b9fb9f5f2a331d8014a3eabbf00e266c05585ae9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:32:53 -0700 Subject: [PATCH 49/61] fix: fence initial SSH root snapshot identity --- ...HForegroundAuthenticationRetryPolicy.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index ace71abdd9bb..d57b9e93e6f9 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -600,6 +600,40 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { return 0 } + # The first process-table snapshot must describe the same kernel + # identity captured before discovery started. PID and PPID alone are + # not sufficient because a wrapper can exit and its PID can be + # reused by another child of this shell before the snapshot runs. + # Darwin's snapshot carries the kernel birth timestamp, while the + # termination token also retains the pid version for the force path. + cmux_ssh_auth_root_snapshot_matches_termination_identity() { + [ -n "$cmux_ssh_auth_root_termination_identity" ] || return 0 + /usr/bin/awk \ + -v cmux_candidate="$1" \ + -v cmux_termination="$cmux_ssh_auth_root_termination_identity" ' + BEGIN { + cmux_candidate_count = split(cmux_candidate, cmux_candidate_fields, /[[:space:]]+/) + cmux_termination_count = split(cmux_termination, cmux_termination_fields, ":") + if (cmux_candidate_count != 4) exit 1 + if (cmux_termination_fields[1] == "P" && cmux_termination_count == 5) { + cmux_started = "P_" cmux_termination_fields[5] "_0_0_0_0" + exit !(cmux_candidate_fields[1] == cmux_termination_fields[2] && + cmux_candidate_fields[2] == cmux_termination_fields[3] && + cmux_candidate_fields[3] == cmux_termination_fields[4] && + cmux_candidate_fields[4] == cmux_started) + } + if (cmux_termination_fields[1] == "D" && cmux_termination_count == 7) { + cmux_started = "K_" cmux_termination_fields[5] "_" cmux_termination_fields[6] "_0_0" + exit !(cmux_candidate_fields[1] == cmux_termination_fields[2] && + cmux_candidate_fields[2] == cmux_termination_fields[3] && + cmux_candidate_fields[3] == cmux_termination_fields[4] && + cmux_candidate_fields[4] == cmux_started) + } + exit 1 + } + ' + } + cmux_ssh_auth_extract_tree() { : > "$cmux_ssh_auth_members" : > "$cmux_ssh_auth_root_identity_candidate" @@ -654,6 +688,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { if ! IFS= read -r cmux_ssh_auth_root_identity_candidate_value < "$cmux_ssh_auth_root_identity_candidate"; then return 1 fi + if ! cmux_ssh_auth_root_snapshot_matches_termination_identity \ + "$cmux_ssh_auth_root_identity_candidate_value"; then + # The captured identity is still safe to use for the root-only + # abort path. Never journal or signal a tree from this snapshot. + cmux_ssh_auth_cleanup_needs_root_abort=1 + return 1 + fi if [ -z "$cmux_ssh_auth_root_identity" ]; then cmux_ssh_auth_root_identity="$cmux_ssh_auth_root_identity_candidate_value" printf '%s\n' "$cmux_ssh_auth_root_identity" > "$cmux_ssh_auth_root_identity_file" || return 1 From 073fc3943e26d2bbfaab26341429b174182a5ed5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:40:48 -0700 Subject: [PATCH 50/61] fix: keep verified SSH descendants on discovery failure --- ...SHForegroundAuthenticationRetryPolicy.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index d57b9e93e6f9..6f904d011b37 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -607,7 +607,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # Darwin's snapshot carries the kernel birth timestamp, while the # termination token also retains the pid version for the force path. cmux_ssh_auth_root_snapshot_matches_termination_identity() { - [ -n "$cmux_ssh_auth_root_termination_identity" ] || return 0 + # A failed identity probe cannot authorize even the initial root + # record. Keep the helper fail-closed instead of falling back to + # PID and PPID matching. + [ -n "$cmux_ssh_auth_root_termination_identity" ] || return 1 /usr/bin/awk \ -v cmux_candidate="$1" \ -v cmux_termination="$cmux_ssh_auth_root_termination_identity" ' @@ -1498,11 +1501,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # identity journal remains valid after reparenting. cmux_ssh_auth_record_dynamic_members || true cmux_ssh_auth_ack_term_event - # Missing marker proof is a cleanup failure. Let the EXIT trap resume - # verified stops and terminate only the identity-fenced root; never - # let an empty dynamic journal declare cleanup complete. + # Missing marker proof cannot authorize a replacement, but the + # original owned journal is already identity-fenced. Continue through + # the bounded force phase so verified descendants do not survive a + # discovery-tool failure. The EXIT trap will still force the known + # root and will leave any unclaimed replacement untouched. if [ "$cmux_ssh_auth_dynamic_discovery_failed" = 1 ]; then - exit 0 + cmux_ssh_auth_cleanup_needs_root_abort=1 fi # Rebuild ownership from exact identities and descendants. Marker-FD @@ -1621,7 +1626,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_kill_failed=0 cmux_ssh_auth_signal_verified_batch KILL \ "$cmux_ssh_auth_kill_candidates" /dev/null || cmux_ssh_auth_kill_failed=1 - if [ "$cmux_ssh_auth_kill_failed" = 0 ]; then + if [ "$cmux_ssh_auth_kill_failed" = 0 ] && + [ "$cmux_ssh_auth_dynamic_discovery_failed" = 0 ]; then cmux_ssh_auth_cleanup_complete=1 else cmux_ssh_auth_cleanup_needs_root_abort=1 From 79b1fd640823b837faf38d70c6ae8591c15c324c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:54:52 -0700 Subject: [PATCH 51/61] docs: clarify libproc audit token signal signature --- .../SSHForegroundAuthenticationRetryPolicy.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 6f904d011b37..e25708fd80c1 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -242,6 +242,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { extend Fiddle::Importer dlload "/usr/lib/libproc.dylib" extern "int proc_pidinfo(int, int, unsigned long long, void*, int)" + # libproc.h declares this private API as + # proc_signal_with_audittoken(audit_token_t *, int). The + # validated pid and pidversion live inside the token. extern "int proc_signal_with_audittoken(void*, int)" end # Read the documented component flavors separately. Flavor @@ -1147,10 +1150,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { exit 2 unless signals.key?(signal_name) && input_path && output_path module CmuxLibproc - extend Fiddle::Importer - dlload "/usr/lib/libproc.dylib" - extern "int proc_pidinfo(int, int, unsigned long long, void*, int)" - extern "int proc_signal_with_audittoken(void*, int)" + extend Fiddle::Importer + dlload "/usr/lib/libproc.dylib" + extern "int proc_pidinfo(int, int, unsigned long long, void*, int)" + # libproc.h declares this private API as + # proc_signal_with_audittoken(audit_token_t *, int). The + # validated pid and pidversion live inside the token. + extern "int proc_signal_with_audittoken(void*, int)" end bsd_flavor = 3 From 2db406d264718c3ed8ad55eed7ee3c4b83123e1c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:03:55 -0700 Subject: [PATCH 52/61] docs: document Darwin process info layout --- .../SSHForegroundAuthenticationRetryPolicy.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index e25708fd80c1..252aa35d4d75 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -261,6 +261,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { proc_bsdinfo_comm_offset = proc_bsdinfo_scalar_size * 12 proc_bsdinfo_name_offset = proc_bsdinfo_comm_offset + 16 proc_bsdinfo_nfiles_offset = proc_bsdinfo_name_offset + 32 + # libproc.h's proc_bsdinfo layout places pbi_pgid after + # pbi_nfiles (offset 100), then pbi_nice (offset 116), with + # the two start-time uint64 values at offsets 120 and 128. proc_bsdinfo_pgid_offset = proc_bsdinfo_nfiles_offset + proc_bsdinfo_scalar_size proc_bsdinfo_start_tvsec_offset = proc_bsdinfo_pgid_offset + proc_bsdinfo_scalar_size * 5 proc_bsdinfo_start_tvusec_offset = proc_bsdinfo_start_tvsec_offset + 8 @@ -1165,10 +1168,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { proc_bsdinfo_status_offset = proc_bsdinfo_scalar_size proc_bsdinfo_pid_offset = proc_bsdinfo_scalar_size * 3 proc_bsdinfo_ppid_offset = proc_bsdinfo_pid_offset + proc_bsdinfo_scalar_size - proc_bsdinfo_comm_offset = proc_bsdinfo_scalar_size * 12 - proc_bsdinfo_name_offset = proc_bsdinfo_comm_offset + 16 - proc_bsdinfo_nfiles_offset = proc_bsdinfo_name_offset + 32 - proc_bsdinfo_pgid_offset = proc_bsdinfo_nfiles_offset + proc_bsdinfo_scalar_size + proc_bsdinfo_comm_offset = proc_bsdinfo_scalar_size * 12 + proc_bsdinfo_name_offset = proc_bsdinfo_comm_offset + 16 + proc_bsdinfo_nfiles_offset = proc_bsdinfo_name_offset + 32 + # libproc.h's proc_bsdinfo layout places pbi_pgid after + # pbi_nfiles (offset 100), then pbi_nice (offset 116), with + # the two start-time uint64 values at offsets 120 and 128. + proc_bsdinfo_pgid_offset = proc_bsdinfo_nfiles_offset + proc_bsdinfo_scalar_size proc_bsdinfo_start_tvsec_offset = proc_bsdinfo_pgid_offset + proc_bsdinfo_scalar_size * 5 proc_bsdinfo_start_tvusec_offset = proc_bsdinfo_start_tvsec_offset + 8 proc_bsdinfo_size = proc_bsdinfo_start_tvusec_offset + 8 From c5971ed7df807b31a9fa5384e991b1f395cc2c4e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:47:22 -0700 Subject: [PATCH 53/61] fix: keep SSH cleanup portable without pidfd or uuidgen --- ...HForegroundAuthenticationRetryPolicy.swift | 125 +++++++++++++----- .../SSHPTYAttachRetryScriptBuilder.swift | 10 +- 2 files changed, 99 insertions(+), 36 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 252aa35d4d75..e55bf8a1c7ca 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -94,7 +94,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { /// a host process-start identity. Darwin uses the microsecond kernel start /// token; Linux uses the monotonic `/proc` start counter. /// A second snapshot must confirm the identity and stopped state before the - /// helper sends `SIGKILL`. After `SIGTERM`, the helper waits for the + /// helper sends `SIGKILL`. Linux uses pidfds when available and an + /// immediately revalidated `kill` fallback on kernels that reject pidfd + /// syscalls. After `SIGTERM`, the helper waits for the /// per-attempt completion FIFO emitted by the authentication wrapper, then /// records descendants that still hold the attempt's marker descriptor. /// Failed snapshots never trigger an unverified signal. This keeps cleanup @@ -314,9 +316,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { P:*) if [ -n "$cmux_ssh_auth_perl_command" ]; then "$cmux_ssh_auth_perl_command" -e ' - use strict; - use warnings; - use POSIX (); + use strict; + use warnings; + use Errno qw(EACCES EINVAL EINTR EMFILE ENFILE ENOSYS EPERM); + use POSIX (); my ($token) = @ARGV; my ($kind, $pid, $parent, $group, $start) = split /:/, $token, -1; exit 0 unless defined $kind && $kind eq "P" && @@ -326,8 +329,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { defined $start && $start =~ /\A[1-9][0-9]*\z/; my $pid_number = int($pid); # Linux exposes pidfd_open and pidfd_send_signal at these - # stable syscall numbers. If this kernel does not provide - # them, fail closed instead of sending to a bare PID. + # stable syscall numbers. Older or sandboxed kernels can + # reject those calls, so the signal helper below performs + # one more identity read before using Perl's kill fallback. my $pidfd_open_syscall = 434; my $pidfd_send_signal_syscall = 424; sub read_identity { @@ -356,19 +360,47 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { }; my $identity = read_identity($pid); exit 0 unless $matches->($identity); - # Force the validated PID to an integer. Perl can pass a - # string scalar as a pointer to syscall on 64-bit hosts. - my $pidfd = syscall($pidfd_open_syscall, $pid_number, 0); - exit 0 unless defined $pidfd && $pidfd >= 0; - my $after_open = read_identity($pid); - unless ($matches->($after_open)) { - POSIX::close($pidfd); - exit 0; - } + my $pidfd_unavailable = 0; + my $send = sub { + my ($signal_number) = @_; + unless ($pidfd_unavailable) { + # Force the validated PID to an integer. Perl can pass + # a string scalar as a pointer to syscall on 64-bit + # hosts. + my $pidfd = syscall($pidfd_open_syscall, $pid_number, 0); + if (defined $pidfd && $pidfd >= 0) { + my $after_open = read_identity($pid); + unless ($matches->($after_open)) { + POSIX::close($pidfd); + return 0; + } + my $result = syscall( + $pidfd_send_signal_syscall, $pidfd, $signal_number, 0, 0 + ); + my $errno = 0 + $!; + POSIX::close($pidfd); + return 1 if defined $result && $result == 0; + return 0 unless $errno == EACCES || $errno == EINVAL || + $errno == EINTR || $errno == EMFILE || $errno == ENFILE || + $errno == ENOSYS || $errno == EPERM; + } else { + my $errno = 0 + $!; + return 0 unless $errno == EACCES || $errno == EINVAL || + $errno == EINTR || $errno == EMFILE || $errno == ENFILE || + $errno == ENOSYS || $errno == EPERM; + } + $pidfd_unavailable = 1; + } + # pidfd is unavailable on this host. Re-read the full + # procfs identity immediately before the compatibility + # signal so a reused PID is never accepted silently. + my $before_kill = read_identity($pid); + return 0 unless $matches->($before_kill); + return kill($signal_number, $pid_number) ? 1 : 0; + }; for my $signal_number (18, 15, 9) { - syscall($pidfd_send_signal_syscall, $pidfd, $signal_number, 0, 0); + $send->($signal_number); } - POSIX::close($pidfd); ' "$cmux_ssh_auth_root_termination_identity" >/dev/null 2>&1 || true fi ;; @@ -978,7 +1010,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # Validate and signal an identity batch. On Darwin, the shell/awk # snapshot is fenced again with the kernel audit token, which carries # the PID version. Linux uses a fresh procfs snapshot and pidfds so a - # PID cannot be reused between validation and signal delivery. + # PID cannot be reused between validation and signal delivery. On an + # older or sandboxed kernel that rejects pidfds, the Perl fallback + # re-reads the full identity immediately before kill(2). # STOP candidates are journaled after the identity-checked request. # A confirming snapshot must prove the stopped state before TERM or # KILL. A stopped process cannot exit and reuse its PID, which closes @@ -1009,12 +1043,14 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_ssh_auth_perl_command" -e ' use strict; use warnings; + use Errno qw(EACCES EINVAL EINTR EMFILE ENFILE ENOSYS EPERM); use POSIX (); my ($signal_name, $input_path, $output_path, $require_stopped) = @ARGV; my %signals = (STOP => 19, TERM => 15, CONT => 18, KILL => 9); # Linux exposes pidfd_open and pidfd_send_signal at these stable - # syscall numbers. If this kernel does not provide them, report - # failure so the caller never falls back to a bare PID signal. + # syscall numbers. Older or sandboxed kernels can reject those + # calls, so the signal closure below performs one more identity + # read before using Perl's compatibility kill path. my $pidfd_open_syscall = 434; my $pidfd_send_signal_syscall = 424; exit 2 unless exists $signals{$signal_name}; @@ -1074,21 +1110,42 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { next if $signal_name =~ /\A(?:TERM|KILL)\z/ && $identity->[0] !~ /T/; next if $require_stopped eq "1" && $identity->[0] !~ /T/ && $signal_name ne "CONT"; - # Force the validated PID to an integer. Perl can pass a - # string scalar as a pointer to syscall on 64-bit hosts. - my $pidfd = syscall($pidfd_open_syscall, $pid_number, 0); - if (!defined $pidfd || $pidfd < 0) { - $failed = 1; - next; - } - my $after_open = read_identity($pid); - unless (matches($after_open, $parent, $group, $expected_start)) { - POSIX::close($pidfd); - next; - } + my $pidfd_unavailable = 0; my $send = sub { - my $result = syscall($pidfd_send_signal_syscall, $pidfd, $_[0], 0, 0); - defined($result) && $result == 0; + my ($signal_number) = @_; + unless ($pidfd_unavailable) { + # Force the validated PID to an integer. Perl can pass a + # string scalar as a pointer to syscall on 64-bit hosts. + my $pidfd = syscall($pidfd_open_syscall, $pid_number, 0); + if (defined $pidfd && $pidfd >= 0) { + my $after_open = read_identity($pid); + unless (matches($after_open, $parent, $group, $expected_start)) { + POSIX::close($pidfd); + return 0; + } + my $result = syscall( + $pidfd_send_signal_syscall, $pidfd, $signal_number, 0, 0 + ); + my $errno = 0 + $!; + POSIX::close($pidfd); + return 1 if defined $result && $result == 0; + return 0 unless $errno == EACCES || $errno == EINVAL || + $errno == EINTR || $errno == EMFILE || $errno == ENFILE || + $errno == ENOSYS || $errno == EPERM; + } else { + my $errno = 0 + $!; + return 0 unless $errno == EACCES || $errno == EINVAL || + $errno == EINTR || $errno == EMFILE || $errno == ENFILE || + $errno == ENOSYS || $errno == EPERM; + } + $pidfd_unavailable = 1; + } + # pidfd is unavailable on this host. Re-read the full + # procfs identity immediately before the compatibility + # signal so a reused PID is never accepted silently. + my $before_kill = read_identity($pid); + return 0 unless matches($before_kill, $parent, $group, $expected_start); + return kill($signal_number, $pid_number) ? 1 : 0; }; if ($signal_name eq "STOP") { if ($send->($signals{STOP})) { diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift index 6ba0148ac609..f50c961438e4 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift @@ -108,11 +108,17 @@ public struct SSHPTYAttachRetryScriptBuilder: Sendable { // authentication; explicit auth/control failures still take the auth // path, preventing credentialed sessions from silently wedging. lines.append(contentsOf: [ + "cmux_ssh_attach_generate_auth_event_token() {", + " cmux_ssh_attach_auth_event_token=", + " if [ -x /usr/bin/uuidgen ]; then cmux_ssh_attach_auth_event_token=$(/usr/bin/uuidgen 2>/dev/null | /usr/bin/tr '[:upper:]' '[:lower:]' || true); fi", + " case \"$cmux_ssh_attach_auth_event_token\" in ''|*[!A-Za-z0-9_-]*) cmux_ssh_attach_auth_event_token= ;; esac", + " if [ -z \"$cmux_ssh_attach_auth_event_token\" ] && [ -r /dev/urandom ] && [ -x /usr/bin/od ] && [ -x /usr/bin/tr ]; then cmux_ssh_attach_auth_event_token=$(/usr/bin/od -An -N16 -tx1 /dev/urandom 2>/dev/null | /usr/bin/tr -d '[:space:]' || true); fi", + " case \"$cmux_ssh_attach_auth_event_token\" in ''|*[!A-Za-z0-9_-]*) cmux_ssh_attach_auth_event_token= ;; esac", + "}", "while :; do", " if [ \"$cmux_ssh_attach_reauth_required\" -eq 1 ]; then", " cmux_ssh_attach_auth_launching=1", - " cmux_ssh_attach_auth_event_token=$(/usr/bin/uuidgen 2>/dev/null | /usr/bin/tr '[:upper:]' '[:lower:]' || true)", - " case \"$cmux_ssh_attach_auth_event_token\" in ''|*[!A-Za-z0-9_-]*) cmux_ssh_attach_auth_event_token= ;; esac", + " cmux_ssh_attach_generate_auth_event_token", " CMUX_SSH_AUTH_EVENT_TOKEN=\"$cmux_ssh_attach_auth_event_token\"; export CMUX_SSH_AUTH_EVENT_TOKEN", " ( cmux_ssh_attach_foreground_auth ) <&0 &", " cmux_ssh_attach_auth_pid=$!", From 34baac5bd17f0178e0ebd2244cff22573bd1c5e0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:48:14 -0700 Subject: [PATCH 54/61] fix: keep embedded cleanup script quoted --- .../SSHForegroundAuthenticationRetryPolicy.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index e55bf8a1c7ca..4faeb7457d3c 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -331,7 +331,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # Linux exposes pidfd_open and pidfd_send_signal at these # stable syscall numbers. Older or sandboxed kernels can # reject those calls, so the signal helper below performs - # one more identity read before using Perl's kill fallback. + # one more identity read before using the Perl kill fallback. my $pidfd_open_syscall = 434; my $pidfd_send_signal_syscall = 424; sub read_identity { @@ -1050,7 +1050,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # Linux exposes pidfd_open and pidfd_send_signal at these stable # syscall numbers. Older or sandboxed kernels can reject those # calls, so the signal closure below performs one more identity - # read before using Perl's compatibility kill path. + # read before using the Perl compatibility kill path. my $pidfd_open_syscall = 434; my $pidfd_send_signal_syscall = 424; exit 2 unless exists $signals{$signal_name}; From 04e875bb8fca95bed60c3ea075a0b46454ad8fc9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:51:19 -0700 Subject: [PATCH 55/61] fix: preserve Darwin shell quoting --- .../SSHForegroundAuthenticationRetryPolicy.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 4faeb7457d3c..52a85222986e 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -263,7 +263,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { proc_bsdinfo_comm_offset = proc_bsdinfo_scalar_size * 12 proc_bsdinfo_name_offset = proc_bsdinfo_comm_offset + 16 proc_bsdinfo_nfiles_offset = proc_bsdinfo_name_offset + 32 - # libproc.h's proc_bsdinfo layout places pbi_pgid after + # libproc.h proc_bsdinfo layout places pbi_pgid after # pbi_nfiles (offset 100), then pbi_nice (offset 116), with # the two start-time uint64 values at offsets 120 and 128. proc_bsdinfo_pgid_offset = proc_bsdinfo_nfiles_offset + proc_bsdinfo_scalar_size @@ -1228,7 +1228,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { proc_bsdinfo_comm_offset = proc_bsdinfo_scalar_size * 12 proc_bsdinfo_name_offset = proc_bsdinfo_comm_offset + 16 proc_bsdinfo_nfiles_offset = proc_bsdinfo_name_offset + 32 - # libproc.h's proc_bsdinfo layout places pbi_pgid after + # libproc.h proc_bsdinfo layout places pbi_pgid after # pbi_nfiles (offset 100), then pbi_nice (offset 116), with # the two start-time uint64 values at offsets 120 and 128. proc_bsdinfo_pgid_offset = proc_bsdinfo_nfiles_offset + proc_bsdinfo_scalar_size From f3c5f940475212be27b5814bd8648ab2f24d0121 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:57:48 -0700 Subject: [PATCH 56/61] fix: compile portable SSH signal helper --- .../SSHForegroundAuthenticationRetryPolicy.swift | 1 - .../SSHForegroundAuthenticationRetryPolicyTests.swift | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 52a85222986e..5a2a88af1f2c 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -1175,7 +1175,6 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { $current->[0] =~ /T/; } } - POSIX::close($pidfd); } close $input; close $output; diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift index 51fe4ef5e142..73a0b02c45ba 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHForegroundAuthenticationRetryPolicyTests.swift @@ -883,7 +883,9 @@ struct SSHForegroundAuthenticationRetryPolicyTests { if Int(size) == expectedSize { return info.pbi_status == UInt32(SZOMB) ? false : true } - if size == 0 && errno == ESRCH { + // proc_pidinfo reports either zero or -1 with ESRCH after a process + // has been reaped. Both results mean the process is no longer live. + if (size == 0 || size < 0) && errno == ESRCH { return false } return nil From 5f91664d434888bdd263eb25b2670bf72b07370e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:03:17 -0700 Subject: [PATCH 57/61] fix: retry SSH completion FIFO setup on cancellation --- .../SSHForegroundAuthenticationRetryPolicy.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 5a2a88af1f2c..d7f7bff25deb 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -1840,7 +1840,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "cmux_ssh_auth_completion_ack_fd=", "cmux_ssh_auth_completion_fds_open=0", "cmux_ssh_auth_prepare_signal_completion() { cmux_ssh_auth_completion_fds_open=0; if [ -n \"$cmux_ssh_auth_event_token\" ] && [ -p \"$cmux_ssh_auth_term_event_fifo\" ] && [ -p \"$cmux_ssh_auth_term_event_ack_fifo\" ] && exec {cmux_ssh_auth_completion_event_fd}<> \"$cmux_ssh_auth_term_event_fifo\" 2>/dev/null && exec {cmux_ssh_auth_completion_ack_fd}<> \"$cmux_ssh_auth_term_event_ack_fifo\" 2>/dev/null; then cmux_ssh_auth_completion_fds_open=1; else exec {cmux_ssh_auth_completion_event_fd}>&- 2>/dev/null || true; exec {cmux_ssh_auth_completion_ack_fd}>&- 2>/dev/null || true; cmux_ssh_auth_completion_event_fd=; cmux_ssh_auth_completion_ack_fd=; fi; }", - "cmux_ssh_auth_signal_completion() { if [ \"$cmux_ssh_auth_completion_fds_open\" = 1 ]; then cmux_ssh_auth_marker_cleanup_deferred=1; printf '%s\\n' \"$cmux_ssh_auth_event_token\" >&$cmux_ssh_auth_completion_event_fd 2>/dev/null || true; cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack <&$cmux_ssh_auth_completion_ack_fd || true; fi; if [ -n \"${cmux_ssh_auth_completion_event_fd:-}\" ]; then exec {cmux_ssh_auth_completion_event_fd}>&- 2>/dev/null || true; fi; if [ -n \"${cmux_ssh_auth_completion_ack_fd:-}\" ]; then exec {cmux_ssh_auth_completion_ack_fd}>&- 2>/dev/null || true; fi; cmux_ssh_auth_completion_event_fd=; cmux_ssh_auth_completion_ack_fd=; cmux_ssh_auth_completion_fds_open=0; }", + // The cleanup helper creates the event FIFOs when cancellation + // begins, after this wrapper has started. Retry the open at signal + // completion so the normal startup race cannot disable the + // completion handshake. + "cmux_ssh_auth_signal_completion() { if [ \"$cmux_ssh_auth_completion_fds_open\" != 1 ]; then cmux_ssh_auth_prepare_signal_completion; fi; if [ \"$cmux_ssh_auth_completion_fds_open\" = 1 ]; then cmux_ssh_auth_marker_cleanup_deferred=1; printf '%s\\n' \"$cmux_ssh_auth_event_token\" >&$cmux_ssh_auth_completion_event_fd 2>/dev/null || true; cmux_ssh_auth_completion_ack=; IFS= read -r -t 2 cmux_ssh_auth_completion_ack <&$cmux_ssh_auth_completion_ack_fd || true; fi; if [ -n \"${cmux_ssh_auth_completion_event_fd:-}\" ]; then exec {cmux_ssh_auth_completion_event_fd}>&- 2>/dev/null || true; fi; if [ -n \"${cmux_ssh_auth_completion_ack_fd:-}\" ]; then exec {cmux_ssh_auth_completion_ack_fd}>&- 2>/dev/null || true; fi; cmux_ssh_auth_completion_event_fd=; cmux_ssh_auth_completion_ack_fd=; cmux_ssh_auth_completion_fds_open=0; }", "cmux_ssh_auth_capture_cleanup() {", " if [ -n \"${cmux_ssh_auth_classifier_guard_fd:-}\" ]; then", " exec {cmux_ssh_auth_classifier_guard_fd}>&-", @@ -1860,8 +1864,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { " cmux_ssh_auth_capture_signal_name=\"$2\"", " trap - EXIT HUP INT TERM", " if [ -n \"${cmux_ssh_auth_command_pid:-}\" ]; then", - " /bin/kill -\"$cmux_ssh_auth_capture_signal_name\" \"$cmux_ssh_auth_command_pid\" >/dev/null 2>&1 || true", " cmux_ssh_auth_prepare_signal_completion", + " /bin/kill -\"$cmux_ssh_auth_capture_signal_name\" \"$cmux_ssh_auth_command_pid\" >/dev/null 2>&1 || true", " wait \"$cmux_ssh_auth_command_pid\" 2>/dev/null || true", " cmux_ssh_auth_command_pid=", " cmux_ssh_auth_signal_completion", From 04012080fe35d37f7f4c17e1c14be5b3fe702e44 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:25:27 -0700 Subject: [PATCH 58/61] fix: recover Darwin SSH cleanup without Ruby --- ...HForegroundAuthenticationRetryPolicy.swift | 343 +++++++++++++++++- 1 file changed, 337 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index d7f7bff25deb..66475c99f721 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -128,6 +128,39 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_root_termination_identity= cmux_ssh_auth_perl_command="$(command -v perl 2>/dev/null || true)" cmux_ssh_auth_lsof_command="$(command -v lsof 2>/dev/null || true)" + cmux_ssh_auth_force_root_shell_fallback() { + # This is a last-resort recovery path when both kernel-aware + # runtimes are unavailable. Restrict it to the captured root and + # require the current parent and process-group tuple to match. + cmux_ssh_auth_fallback_token="$1" + cmux_ssh_auth_fallback_kind="${cmux_ssh_auth_fallback_token%%:*}" + case "$cmux_ssh_auth_fallback_kind" in + D|K) ;; + *) return 1 ;; + esac + cmux_ssh_auth_fallback_rest="${cmux_ssh_auth_fallback_token#*:}" + cmux_ssh_auth_fallback_pid="${cmux_ssh_auth_fallback_rest%%:*}" + cmux_ssh_auth_fallback_rest="${cmux_ssh_auth_fallback_rest#*:}" + cmux_ssh_auth_fallback_parent="${cmux_ssh_auth_fallback_rest%%:*}" + cmux_ssh_auth_fallback_rest="${cmux_ssh_auth_fallback_rest#*:}" + cmux_ssh_auth_fallback_group="${cmux_ssh_auth_fallback_rest%%:*}" + case "$cmux_ssh_auth_fallback_pid:$cmux_ssh_auth_fallback_parent:$cmux_ssh_auth_fallback_group" in + ''|*[!0-9:]*|*:|*:) return 1 ;; + esac + cmux_ssh_auth_fallback_current=$( + /bin/ps -o ppid= -o pgid= -o state= -p "$cmux_ssh_auth_fallback_pid" \ + 2>/dev/null || true + ) + set -- $cmux_ssh_auth_fallback_current + [ "$#" -ge 3 ] || return 1 + [ "$1" = "$cmux_ssh_auth_fallback_parent" ] || return 1 + [ "$2" = "$cmux_ssh_auth_fallback_group" ] || return 1 + case "$3" in *Z*) return 0 ;; esac + /bin/kill -CONT "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || true + /bin/kill -TERM "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || true + /bin/kill -KILL "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || true + return 0 + } cmux_ssh_auth_capture_root_termination_identity() { case "$cmux_ssh_auth_platform" in Darwin) @@ -190,8 +223,36 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { group > 0 && status != 5 && seconds > 0 && microseconds < 1_000_000 && version > 0 puts "D:#{pid}:#{parent}:#{group}:#{seconds}:#{microseconds}:#{version}" - ' "$cmux_ssh_auth_tree_root_pid" "$cmux_ssh_auth_tree_root_parent" 2>/dev/null + ' "$cmux_ssh_auth_tree_root_pid" "$cmux_ssh_auth_tree_root_parent" 2>/dev/null ) || cmux_ssh_auth_root_termination_identity= + if [ -z "$cmux_ssh_auth_root_termination_identity" ] && [ -x /usr/bin/perl ]; then + cmux_ssh_auth_root_termination_identity=$( + /usr/bin/perl -e ' + use strict; + use warnings; + my ($pid, $expected_parent) = @ARGV; + for my $size (136, 184) { + my $buffer = "\0" x $size; + my $written = syscall(336, 2, int($pid), 3, 0, $buffer, $size); + next unless defined $written && $written == $size; + my ($group_offset, $seconds_offset, $microseconds_offset) = + $size == 136 ? (100, 120, 128) : (148, 168, 176); + my $status = unpack("L<", substr($buffer, 4, 4)); + my $observed_pid = unpack("L<", substr($buffer, 12, 4)); + my $parent = unpack("L<", substr($buffer, 16, 4)); + my $group = unpack("L<", substr($buffer, $group_offset, 4)); + my $seconds = unpack("Q<", substr($buffer, $seconds_offset, 8)); + my $microseconds = unpack("Q<", substr($buffer, $microseconds_offset, 8)); + next unless $observed_pid == $pid && $parent == $expected_parent && + $group > 0 && $status != 5 && $seconds > 0 && + $microseconds < 1_000_000; + print "K:$pid:$parent:$group:$seconds:$microseconds:0\n"; + exit 0; + } + exit 1; + ' "$cmux_ssh_auth_tree_root_pid" "$cmux_ssh_auth_tree_root_parent" 2>/dev/null + ) || cmux_ssh_auth_root_termination_identity= + fi ;; *) if [ -n "$cmux_ssh_auth_perl_command" ] && @@ -222,7 +283,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { ;; esac case "$cmux_ssh_auth_root_termination_identity" in - D:*|P:*) ;; + D:*|K:*|P:*) ;; *) cmux_ssh_auth_root_termination_identity= ;; esac } @@ -309,9 +370,59 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { audit_token[20, 4] = [pid].pack("L<") audit_token[28, 4] = [version].pack("L<") [19, 15, 9].each do |signal_number| - CmuxLibproc.proc_signal_with_audittoken(audit_token, signal_number) + exit 1 unless CmuxLibproc.proc_signal_with_audittoken(audit_token, signal_number) == 0 end - ' "$cmux_ssh_auth_root_termination_identity" >/dev/null 2>&1 || true + ' "$cmux_ssh_auth_root_termination_identity" >/dev/null 2>&1 + cmux_ssh_auth_force_status=$? + if [ "$cmux_ssh_auth_force_status" -ne 0 ]; then + cmux_ssh_auth_force_root_shell_fallback "$cmux_ssh_auth_root_termination_identity" || true + fi + ;; + K:*) + if [ -x /usr/bin/perl ]; then + /usr/bin/perl -e ' + use strict; + use warnings; + my ($token) = @ARGV; + my @fields = split /:/, $token, -1; + exit 0 unless @fields == 7 && $fields[0] eq "K"; + my ($pid, $parent, $group, $seconds, $microseconds) = @fields[1..5]; + exit 0 unless $pid =~ /\A[1-9][0-9]*\z/ && $parent =~ /\A[0-9]+\z/ && + $group =~ /\A[1-9][0-9]*\z/ && $seconds =~ /\A[1-9][0-9]*\z/ && + $microseconds =~ /\A[0-9]+\z/ && $microseconds < 1_000_000; + my $pid_number = int($pid); + my $matched = 0; + for my $size (136, 184) { + my $buffer = "\0" x $size; + my $written = syscall(336, 2, $pid_number, 3, 0, $buffer, $size); + next unless defined $written && $written == $size; + my ($group_offset, $seconds_offset, $microseconds_offset) = + $size == 136 ? (100, 120, 128) : (148, 168, 176); + my $status = unpack("L<", substr($buffer, 4, 4)); + my $observed_pid = unpack("L<", substr($buffer, 12, 4)); + my $observed_parent = unpack("L<", substr($buffer, 16, 4)); + my $observed_group = unpack("L<", substr($buffer, $group_offset, 4)); + my $observed_seconds = unpack("Q<", substr($buffer, $seconds_offset, 8)); + my $observed_microseconds = unpack("Q<", substr($buffer, $microseconds_offset, 8)); + if ($observed_pid == $pid_number && $observed_parent == $parent && + $observed_group == $group && $status != 5 && + $observed_seconds == $seconds && $observed_microseconds == $microseconds) { + $matched = 1; + last; + } + } + exit 0 unless $matched; + exit 1 unless kill(18, $pid_number); + exit 1 unless kill(15, $pid_number); + exit 1 unless kill(9, $pid_number); + ' "$cmux_ssh_auth_root_termination_identity" >/dev/null 2>&1 + cmux_ssh_auth_force_status=$? + if [ "$cmux_ssh_auth_force_status" -ne 0 ]; then + cmux_ssh_auth_force_root_shell_fallback "$cmux_ssh_auth_root_termination_identity" || true + fi + else + cmux_ssh_auth_force_root_shell_fallback "$cmux_ssh_auth_root_termination_identity" || true + fi ;; P:*) if [ -n "$cmux_ssh_auth_perl_command" ]; then @@ -670,6 +781,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_candidate_fields[3] == cmux_termination_fields[4] && cmux_candidate_fields[4] == cmux_started) } + if (cmux_termination_fields[1] == "K" && cmux_termination_count == 7) { + cmux_started = "K_" cmux_termination_fields[5] "_" cmux_termination_fields[6] "_0_0" + exit !(cmux_candidate_fields[1] == cmux_termination_fields[2] && + cmux_candidate_fields[2] == cmux_termination_fields[3] && + cmux_candidate_fields[3] == cmux_termination_fields[4] && + cmux_candidate_fields[4] == cmux_started) + } exit 1 } ' @@ -1186,6 +1304,205 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { [ "$cmux_ssh_auth_portable_status" -eq 0 ] } + cmux_ssh_auth_signal_darwin_fallback_batch() { + cmux_ssh_auth_fallback_signal_name="$1" + cmux_ssh_auth_fallback_signal_input="$2" + cmux_ssh_auth_fallback_signal_output="${3:-/dev/null}" + if [ -x /usr/bin/perl ]; then + /usr/bin/perl -e ' + use strict; + use warnings; + my ($signal_name, $input_path, $output_path) = @ARGV; + my %signals = (STOP => 17, TERM => 15, CONT => 19, KILL => 9); + exit 2 unless exists $signals{$signal_name}; + sub read_identity { + my ($pid) = @_; + for my $size (136, 184) { + my $buffer = "\0" x $size; + my $written = syscall(336, 2, int($pid), 3, 0, $buffer, $size); + next unless defined $written && $written == $size; + my ($group_offset, $seconds_offset, $microseconds_offset) = + $size == 136 ? (100, 120, 128) : (148, 168, 176); + return [ + unpack("L<", substr($buffer, 12, 4)), + unpack("L<", substr($buffer, 16, 4)), + unpack("L<", substr($buffer, $group_offset, 4)), + unpack("L<", substr($buffer, 4, 4)), + unpack("Q<", substr($buffer, $seconds_offset, 8)), + unpack("Q<", substr($buffer, $microseconds_offset, 8)) + ]; + } + return; + } + sub matches { + my ($identity, $pid, $group, $seconds, $microseconds) = @_; + return defined $identity && $identity->[0] == $pid && + $identity->[2] == $group && $identity->[4] == $seconds && + $identity->[5] == $microseconds; + } + open my $input, "<", $input_path or exit 1; + open my $output, ">", $output_path or exit 1; + my $failed = 0; + while (my $line = <$input>) { + chomp $line; + my @fields = split /\s+/, $line; + next unless @fields == 6; + my ($depth, $pid_text, $parent_text, $group_text, $original_state, $started) = @fields; + next unless $depth =~ /\A[0-9]+\z/ && $pid_text =~ /\A[1-9][0-9]*\z/ && + $parent_text =~ /\A[0-9]+\z/ && $group_text =~ /\A[1-9][0-9]*\z/; + my ($seconds, $microseconds) = $started =~ /\AK_([0-9]+)_([0-9]+)_0_0\z/; + next unless defined $seconds && defined $microseconds && $microseconds < 1_000_000; + my $pid = int($pid_text); + my $group = int($group_text); + my $before = read_identity($pid); + next unless matches($before, $pid, $group, int($seconds), int($microseconds)); + next if $before->[3] == 5; + my $send = sub { kill($signals{$_[0]}, $pid) ? 1 : 0 }; + if ($signal_name eq "STOP") { + if ($before->[3] == 4) { + print {$output} "$line\n" if $original_state eq "T"; + next; + } + if ($send->("STOP")) { + print {$output} "$line\n"; + } else { + $failed = 1; + } + next; + } + next if $signal_name eq "CONT" && $original_state eq "T"; + if ($signal_name eq "CONT") { + unless ($send->("CONT")) { + my $current = read_identity($pid); + $failed = 1 if matches($current, $pid, $group, int($seconds), int($microseconds)) && + $current->[3] == 4; + } + next; + } + next unless $before->[3] == 4; + if ($signal_name eq "TERM") { + my $term_ok = $send->("TERM"); + my $current = read_identity($pid); + if (matches($current, $pid, $group, int($seconds), int($microseconds)) && + $current->[3] == 4) { + my $cont_ok = $send->("CONT"); + $failed = 1 unless $term_ok && $cont_ok; + } + next; + } + unless ($send->("KILL")) { + my $current = read_identity($pid); + $failed = 1 if matches($current, $pid, $group, int($seconds), int($microseconds)) && + $current->[3] == 4; + } + } + close $input; + close $output; + exit $failed ? 1 : 0; + ' "$cmux_ssh_auth_fallback_signal_name" \ + "$cmux_ssh_auth_fallback_signal_input" "$cmux_ssh_auth_fallback_signal_output" \ + >/dev/null 2>&1 + cmux_ssh_auth_fallback_status=$? + if [ "$cmux_ssh_auth_fallback_status" -eq 0 ]; then + return 0 + fi + fi + # Keep a final recovery path even on stripped systems without + # Perl. The candidate came from a fresh kernel snapshot, and this + # check repeats its parent, group, and state fence before each + # standard kill. It is weaker than the pid-version path, so it is + # used only after both exact backends fail. + : > "$cmux_ssh_auth_fallback_signal_output" || return 1 + cmux_ssh_auth_fallback_failed=0 + while IFS=' ' read -r cmux_ssh_auth_fallback_depth cmux_ssh_auth_fallback_pid \ + cmux_ssh_auth_fallback_parent cmux_ssh_auth_fallback_group \ + cmux_ssh_auth_fallback_original_state cmux_ssh_auth_fallback_started; do + case "$cmux_ssh_auth_fallback_depth:$cmux_ssh_auth_fallback_pid:$cmux_ssh_auth_fallback_parent:$cmux_ssh_auth_fallback_group" in + ''|*[!0-9:]*|*:|*:) continue ;; + esac + case "$cmux_ssh_auth_fallback_started" in + K_[0-9]*_[0-9]*_0_0) ;; + *) continue ;; + esac + cmux_ssh_auth_fallback_current=$( + /bin/ps -o ppid= -o pgid= -o state= -p "$cmux_ssh_auth_fallback_pid" \ + 2>/dev/null || true + ) + set -- $cmux_ssh_auth_fallback_current + [ "$#" -ge 3 ] || continue + cmux_ssh_auth_fallback_observed_parent="$1" + cmux_ssh_auth_fallback_observed_group="$2" + cmux_ssh_auth_fallback_observed_state="$3" + [ "$cmux_ssh_auth_fallback_observed_parent" = "$cmux_ssh_auth_fallback_parent" ] || continue + [ "$cmux_ssh_auth_fallback_observed_group" = "$cmux_ssh_auth_fallback_group" ] || continue + case "$cmux_ssh_auth_fallback_observed_state" in *Z*) continue ;; esac + case "$cmux_ssh_auth_fallback_signal_name" in + STOP) + case "$cmux_ssh_auth_fallback_observed_state" in + *T*) + [ "$cmux_ssh_auth_fallback_original_state" = T ] && + printf '%s\n' "$cmux_ssh_auth_fallback_depth $cmux_ssh_auth_fallback_pid $cmux_ssh_auth_fallback_parent $cmux_ssh_auth_fallback_group $cmux_ssh_auth_fallback_original_state $cmux_ssh_auth_fallback_started" >> "$cmux_ssh_auth_fallback_signal_output" + continue + ;; + esac + if /bin/kill -STOP "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1; then + printf '%s\n' "$cmux_ssh_auth_fallback_depth $cmux_ssh_auth_fallback_pid $cmux_ssh_auth_fallback_parent $cmux_ssh_auth_fallback_group $cmux_ssh_auth_fallback_original_state $cmux_ssh_auth_fallback_started" >> "$cmux_ssh_auth_fallback_signal_output" + else + cmux_ssh_auth_fallback_failed=1 + fi + ;; + CONT) + [ "$cmux_ssh_auth_fallback_original_state" = T ] && continue + if ! /bin/kill -CONT "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1; then + cmux_ssh_auth_fallback_current=$( + /bin/ps -o ppid= -o pgid= -o state= -p "$cmux_ssh_auth_fallback_pid" \ + 2>/dev/null || true + ) + set -- $cmux_ssh_auth_fallback_current + if [ "$#" -ge 3 ] && [ "$1" = "$cmux_ssh_auth_fallback_parent" ] && + [ "$2" = "$cmux_ssh_auth_fallback_group" ]; then + case "$3" in *T*) cmux_ssh_auth_fallback_failed=1 ;; esac + fi + fi + ;; + TERM) + case "$cmux_ssh_auth_fallback_observed_state" in *T*) ;; *) continue ;; esac + cmux_ssh_auth_fallback_term_status=0 + cmux_ssh_auth_fallback_cont_status=0 + /bin/kill -TERM "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || cmux_ssh_auth_fallback_term_status=$? + /bin/kill -CONT "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || cmux_ssh_auth_fallback_cont_status=$? + if [ "$cmux_ssh_auth_fallback_term_status" -ne 0 ] || + [ "$cmux_ssh_auth_fallback_cont_status" -ne 0 ]; then + cmux_ssh_auth_fallback_current=$( + /bin/ps -o ppid= -o pgid= -o state= -p "$cmux_ssh_auth_fallback_pid" \ + 2>/dev/null || true + ) + set -- $cmux_ssh_auth_fallback_current + if [ "$#" -ge 3 ] && [ "$1" = "$cmux_ssh_auth_fallback_parent" ] && + [ "$2" = "$cmux_ssh_auth_fallback_group" ]; then + case "$3" in *T*) cmux_ssh_auth_fallback_failed=1 ;; esac + fi + fi + ;; + KILL) + case "$cmux_ssh_auth_fallback_observed_state" in *T*) ;; *) continue ;; esac + if ! /bin/kill -KILL "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1; then + cmux_ssh_auth_fallback_current=$( + /bin/ps -o ppid= -o pgid= -o state= -p "$cmux_ssh_auth_fallback_pid" \ + 2>/dev/null || true + ) + set -- $cmux_ssh_auth_fallback_current + if [ "$#" -ge 3 ] && [ "$1" = "$cmux_ssh_auth_fallback_parent" ] && + [ "$2" = "$cmux_ssh_auth_fallback_group" ]; then + case "$3" in *T*) cmux_ssh_auth_fallback_failed=1 ;; esac + fi + fi + ;; + esac + done < "$cmux_ssh_auth_fallback_signal_input" + return "$cmux_ssh_auth_fallback_failed" + } + cmux_ssh_auth_signal_verified_batch() { cmux_ssh_auth_signal_name="$1" cmux_ssh_auth_signal_input="$2" @@ -1311,7 +1628,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { output.puts(line.chomp) if original_state == "T" next end - next unless signal_exact.call(before, signals[signal_name]) + unless signal_exact.call(before, signals[signal_name]) + current = process_identity.call(pid) + signal_failed = true if same_identity.call( + current, pid, group, expected_seconds, expected_microseconds + ) + next + end # SIGSTOP delivery can be asynchronous to proc_pidinfo. The # audit-token call already verified this exact identity, so # journal every successful STOP immediately. A later pass @@ -1338,9 +1661,11 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { if signal_name == "TERM" unless signal_exact.call(before, signals[signal_name]) current = process_identity.call(pid) - signal_exact.call(current, signals["CONT"]) if same_identity.call( + if same_identity.call( current, pid, group, expected_seconds, expected_microseconds ) && current[3] == 4 + signal_failed = true unless signal_exact.call(current, signals["CONT"]) + end next end after = process_identity.call(pid) @@ -1367,6 +1692,12 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { ' "$cmux_ssh_auth_signal_name" "$cmux_ssh_auth_signal_input" \ "$cmux_ssh_auth_signal_output" >/dev/null 2>&1 cmux_ssh_auth_signal_status=$? + if [ "$cmux_ssh_auth_signal_status" -ne 0 ]; then + cmux_ssh_auth_signal_darwin_fallback_batch \ + "$cmux_ssh_auth_signal_name" "$cmux_ssh_auth_signal_input" \ + "$cmux_ssh_auth_signal_output" + cmux_ssh_auth_signal_status=$? + fi if [ "$cmux_ssh_auth_signal_status" -ne 0 ]; then cmux_ssh_auth_cleanup_needs_root_abort=1 fi From dd960b69867acaff1fda99254510ee05d8ede902 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:44:57 -0700 Subject: [PATCH 59/61] fix: keep SSH cleanup portable without Perl --- ...HForegroundAuthenticationRetryPolicy.swift | 418 ++++++++++-------- 1 file changed, 245 insertions(+), 173 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 66475c99f721..52b2f2a1a2a0 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -128,14 +128,52 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_root_termination_identity= cmux_ssh_auth_perl_command="$(command -v perl 2>/dev/null || true)" cmux_ssh_auth_lsof_command="$(command -v lsof 2>/dev/null || true)" - cmux_ssh_auth_force_root_shell_fallback() { - # This is a last-resort recovery path when both kernel-aware - # runtimes are unavailable. Restrict it to the captured root and - # require the current parent and process-group tuple to match. + cmux_ssh_auth_read_proc_stat() { + cmux_ssh_auth_proc_pid="$1" + case "$cmux_ssh_auth_proc_pid" in + ''|*[!0-9]*) return 1 ;; + esac + cmux_ssh_auth_proc_path="/proc/$cmux_ssh_auth_proc_pid/stat" + [ -r "$cmux_ssh_auth_proc_path" ] || return 1 + IFS= read -r cmux_ssh_auth_proc_line < "$cmux_ssh_auth_proc_path" || return 1 + cmux_ssh_auth_proc_observed_pid="${cmux_ssh_auth_proc_line%% *}" + cmux_ssh_auth_proc_tail="${cmux_ssh_auth_proc_line##*) }" + [ "$cmux_ssh_auth_proc_observed_pid" = "$cmux_ssh_auth_proc_pid" ] || return 1 + [ "$cmux_ssh_auth_proc_tail" != "$cmux_ssh_auth_proc_line" ] || return 1 + set -- $cmux_ssh_auth_proc_tail + [ "$#" -ge 20 ] || return 1 + cmux_ssh_auth_proc_state="$1" + cmux_ssh_auth_proc_parent="$2" + cmux_ssh_auth_proc_group="$3" + cmux_ssh_auth_proc_start="${20}" + case "$cmux_ssh_auth_proc_state" in + t) cmux_ssh_auth_proc_state=T ;; + z) cmux_ssh_auth_proc_state=Z ;; + esac + case "$cmux_ssh_auth_proc_state" in + [A-Za-z]) ;; + *) return 1 ;; + esac + case "$cmux_ssh_auth_proc_parent" in + ''|*[!0-9]*) return 1 ;; + esac + case "$cmux_ssh_auth_proc_group" in + ''|0|0*|*[!0-9]*) return 1 ;; + esac + case "$cmux_ssh_auth_proc_start" in + ''|0|0*|*[!0-9]*) return 1 ;; + esac + return 0 + } + cmux_ssh_auth_force_root_procfs_fallback() { + # Linux has no kernel pidfd backend when Perl is unavailable, so + # re-read the complete procfs identity before each root signal. + # Do not use this path for Darwin tokens: without libproc there is + # no safe shell interface for the kernel birth identity. cmux_ssh_auth_fallback_token="$1" cmux_ssh_auth_fallback_kind="${cmux_ssh_auth_fallback_token%%:*}" case "$cmux_ssh_auth_fallback_kind" in - D|K) ;; + P) ;; *) return 1 ;; esac cmux_ssh_auth_fallback_rest="${cmux_ssh_auth_fallback_token#*:}" @@ -144,22 +182,65 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { cmux_ssh_auth_fallback_parent="${cmux_ssh_auth_fallback_rest%%:*}" cmux_ssh_auth_fallback_rest="${cmux_ssh_auth_fallback_rest#*:}" cmux_ssh_auth_fallback_group="${cmux_ssh_auth_fallback_rest%%:*}" - case "$cmux_ssh_auth_fallback_pid:$cmux_ssh_auth_fallback_parent:$cmux_ssh_auth_fallback_group" in + cmux_ssh_auth_fallback_start="${cmux_ssh_auth_fallback_rest#*:}" + case "$cmux_ssh_auth_fallback_pid:$cmux_ssh_auth_fallback_parent:$cmux_ssh_auth_fallback_group:$cmux_ssh_auth_fallback_start" in ''|*[!0-9:]*|*:|*:) return 1 ;; esac - cmux_ssh_auth_fallback_current=$( - /bin/ps -o ppid= -o pgid= -o state= -p "$cmux_ssh_auth_fallback_pid" \ - 2>/dev/null || true - ) - set -- $cmux_ssh_auth_fallback_current - [ "$#" -ge 3 ] || return 1 - [ "$1" = "$cmux_ssh_auth_fallback_parent" ] || return 1 - [ "$2" = "$cmux_ssh_auth_fallback_group" ] || return 1 - case "$3" in *Z*) return 0 ;; esac + cmux_ssh_auth_fallback_start="${cmux_ssh_auth_fallback_start%%:*}" + case "$cmux_ssh_auth_fallback_start" in + [1-9][0-9]*) ;; + *) return 1 ;; + esac + cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_fallback_pid" || return 1 + [ "$cmux_ssh_auth_proc_parent" = "$cmux_ssh_auth_fallback_parent" ] || return 1 + [ "$cmux_ssh_auth_proc_group" = "$cmux_ssh_auth_fallback_group" ] || return 1 + [ "$cmux_ssh_auth_proc_start" = "$cmux_ssh_auth_fallback_start" ] || return 1 + case "$cmux_ssh_auth_proc_state" in Z) return 0 ;; esac /bin/kill -CONT "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || true /bin/kill -TERM "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || true /bin/kill -KILL "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || true - return 0 + } + cmux_ssh_auth_force_root_darwin_perl_fallback() { + [ -x /usr/bin/perl ] || return 1 + /usr/bin/perl -e ' + use strict; + use warnings; + my ($token) = @ARGV; + my @fields = split /:/, $token, -1; + exit 1 unless @fields == 7 && ($fields[0] eq "D" || $fields[0] eq "K"); + my ($pid, $parent, $group, $seconds, $microseconds) = @fields[1..5]; + exit 1 unless $pid =~ /\A[1-9][0-9]*\z/ && $parent =~ /\A[0-9]+\z/ && + $group =~ /\A[1-9][0-9]*\z/ && $seconds =~ /\A[1-9][0-9]*\z/ && + $microseconds =~ /\A[0-9]+\z/ && $microseconds < 1_000_000; + my $pid_number = int($pid); + my $matched = 0; + for my $size (136, 184) { + my $buffer = "\0" x $size; + my $written = syscall(336, 2, $pid_number, 3, 0, $buffer, $size); + next unless defined $written && $written == $size; + my ($group_offset, $seconds_offset, $microseconds_offset) = + $size == 136 ? (100, 120, 128) : (148, 168, 176); + my $status = unpack("L<", substr($buffer, 4, 4)); + my $observed_pid = unpack("L<", substr($buffer, 12, 4)); + my $observed_parent = unpack("L<", substr($buffer, 16, 4)); + my $observed_group = unpack("L<", substr($buffer, $group_offset, 4)); + my $observed_seconds = unpack("Q<", substr($buffer, $seconds_offset, 8)); + my $observed_microseconds = unpack("Q<", substr($buffer, $microseconds_offset, 8)); + if ($observed_pid == $pid_number && $observed_parent == $parent && + $observed_group == $group && $status != 5 && + $observed_seconds == $seconds && $observed_microseconds == $microseconds) { + $matched = 1; + last; + } + } + exit 1 unless $matched; + my $failed = 0; + for my $signal_number (18, 15, 9) { + next if kill($signal_number, $pid_number); + $failed = 1 if kill(0, $pid_number); + } + exit $failed; + ' "$1" >/dev/null 2>&1 } cmux_ssh_auth_capture_root_termination_identity() { case "$cmux_ssh_auth_platform" in @@ -280,6 +361,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { ' "$cmux_ssh_auth_tree_root_pid" "$cmux_ssh_auth_tree_root_parent" 2>/dev/null ) || cmux_ssh_auth_root_termination_identity= fi + if [ -z "$cmux_ssh_auth_root_termination_identity" ] && + cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_tree_root_pid"; then + if [ "$cmux_ssh_auth_proc_parent" = "$cmux_ssh_auth_tree_root_parent" ] && + [ "$cmux_ssh_auth_proc_state" != Z ]; then + cmux_ssh_auth_root_termination_identity="P:$cmux_ssh_auth_tree_root_pid:$cmux_ssh_auth_proc_parent:$cmux_ssh_auth_proc_group:$cmux_ssh_auth_proc_start" + fi + fi ;; esac case "$cmux_ssh_auth_root_termination_identity" in @@ -375,54 +463,12 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { ' "$cmux_ssh_auth_root_termination_identity" >/dev/null 2>&1 cmux_ssh_auth_force_status=$? if [ "$cmux_ssh_auth_force_status" -ne 0 ]; then - cmux_ssh_auth_force_root_shell_fallback "$cmux_ssh_auth_root_termination_identity" || true + cmux_ssh_auth_force_root_darwin_perl_fallback "$cmux_ssh_auth_root_termination_identity" || true fi ;; K:*) - if [ -x /usr/bin/perl ]; then - /usr/bin/perl -e ' - use strict; - use warnings; - my ($token) = @ARGV; - my @fields = split /:/, $token, -1; - exit 0 unless @fields == 7 && $fields[0] eq "K"; - my ($pid, $parent, $group, $seconds, $microseconds) = @fields[1..5]; - exit 0 unless $pid =~ /\A[1-9][0-9]*\z/ && $parent =~ /\A[0-9]+\z/ && - $group =~ /\A[1-9][0-9]*\z/ && $seconds =~ /\A[1-9][0-9]*\z/ && - $microseconds =~ /\A[0-9]+\z/ && $microseconds < 1_000_000; - my $pid_number = int($pid); - my $matched = 0; - for my $size (136, 184) { - my $buffer = "\0" x $size; - my $written = syscall(336, 2, $pid_number, 3, 0, $buffer, $size); - next unless defined $written && $written == $size; - my ($group_offset, $seconds_offset, $microseconds_offset) = - $size == 136 ? (100, 120, 128) : (148, 168, 176); - my $status = unpack("L<", substr($buffer, 4, 4)); - my $observed_pid = unpack("L<", substr($buffer, 12, 4)); - my $observed_parent = unpack("L<", substr($buffer, 16, 4)); - my $observed_group = unpack("L<", substr($buffer, $group_offset, 4)); - my $observed_seconds = unpack("Q<", substr($buffer, $seconds_offset, 8)); - my $observed_microseconds = unpack("Q<", substr($buffer, $microseconds_offset, 8)); - if ($observed_pid == $pid_number && $observed_parent == $parent && - $observed_group == $group && $status != 5 && - $observed_seconds == $seconds && $observed_microseconds == $microseconds) { - $matched = 1; - last; - } - } - exit 0 unless $matched; - exit 1 unless kill(18, $pid_number); - exit 1 unless kill(15, $pid_number); - exit 1 unless kill(9, $pid_number); - ' "$cmux_ssh_auth_root_termination_identity" >/dev/null 2>&1 - cmux_ssh_auth_force_status=$? - if [ "$cmux_ssh_auth_force_status" -ne 0 ]; then - cmux_ssh_auth_force_root_shell_fallback "$cmux_ssh_auth_root_termination_identity" || true - fi - else - cmux_ssh_auth_force_root_shell_fallback "$cmux_ssh_auth_root_termination_identity" || true - fi + cmux_ssh_auth_force_root_darwin_perl_fallback \ + "$cmux_ssh_auth_root_termination_identity" || true ;; P:*) if [ -n "$cmux_ssh_auth_perl_command" ]; then @@ -513,6 +559,9 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { $send->($signal_number); } ' "$cmux_ssh_auth_root_termination_identity" >/dev/null 2>&1 || true + else + cmux_ssh_auth_force_root_procfs_fallback \ + "$cmux_ssh_auth_root_termination_identity" || true fi ;; esac @@ -697,14 +746,26 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # is not an identity fence under PID reuse. cmux_ssh_auth_signal_backend=portable fi - if [ ! -r /proc/1/stat ]; then - cmux_ssh_auth_cleanup_needs_root_abort=1 - return 1 - fi cmux_ssh_auth_perl_command=$(command -v perl 2>/dev/null || true) if [ -z "$cmux_ssh_auth_perl_command" ]; then - cmux_ssh_auth_cleanup_needs_root_abort=1 - return 1 + : > "$cmux_ssh_auth_snapshot" || return 1 + for cmux_ssh_auth_proc_path in /proc/[0-9]*/stat; do + [ -r "$cmux_ssh_auth_proc_path" ] || continue + cmux_ssh_auth_proc_pid="${cmux_ssh_auth_proc_path#/proc/}" + cmux_ssh_auth_proc_pid="${cmux_ssh_auth_proc_pid%/stat}" + if cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_proc_pid"; then + printf '%s %s %s %s P_%s 0 0 0 0\n' \ + "$cmux_ssh_auth_proc_pid" "$cmux_ssh_auth_proc_parent" \ + "$cmux_ssh_auth_proc_group" "$cmux_ssh_auth_proc_state" \ + "$cmux_ssh_auth_proc_start" >> "$cmux_ssh_auth_snapshot" + fi + done + if [ ! -s "$cmux_ssh_auth_snapshot" ]; then + cmux_ssh_auth_cleanup_needs_root_abort=1 + return 1 + fi + cmux_ssh_auth_snapshot_format=portable + return 0 fi if [ -n "$cmux_ssh_auth_snapshot_format" ] && [ "$cmux_ssh_auth_snapshot_format" != portable ]; then @@ -1130,7 +1191,10 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { # the PID version. Linux uses a fresh procfs snapshot and pidfds so a # PID cannot be reused between validation and signal delivery. On an # older or sandboxed kernel that rejects pidfds, the Perl fallback - # re-reads the full identity immediately before kill(2). + # re-reads the full identity immediately before kill(2). If Perl is + # absent, the shell procfs path applies the same start-time fence. + # Darwin keeps the Perl/libproc fallback and fails closed if neither + # kernel-aware runtime is available. # STOP candidates are journaled after the identity-checked request. # A confirming snapshot must prove the stopped state before TERM or # KILL. A stopped process cannot exit and reuse its PID, which closes @@ -1304,7 +1368,103 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { [ "$cmux_ssh_auth_portable_status" -eq 0 ] } - cmux_ssh_auth_signal_darwin_fallback_batch() { + cmux_ssh_auth_signal_procfs_batch() { + cmux_ssh_auth_procfs_signal_name="$1" + cmux_ssh_auth_procfs_signal_input="$2" + cmux_ssh_auth_procfs_signal_output="${3:-/dev/null}" + case "$cmux_ssh_auth_procfs_signal_name" in + STOP|TERM|KILL|CONT) ;; + *) return 2 ;; + esac + [ -d /proc ] || return 1 + : > "$cmux_ssh_auth_procfs_signal_output" || return 1 + cmux_ssh_auth_procfs_failed=0 + while IFS=' ' read -r cmux_ssh_auth_procfs_depth cmux_ssh_auth_procfs_pid \ + cmux_ssh_auth_procfs_parent cmux_ssh_auth_procfs_group \ + cmux_ssh_auth_procfs_original_state cmux_ssh_auth_procfs_started; do + case "$cmux_ssh_auth_procfs_depth:$cmux_ssh_auth_procfs_pid:$cmux_ssh_auth_procfs_parent:$cmux_ssh_auth_procfs_group" in + ''|*[!0-9:]*|*:|*:) continue ;; + esac + case "$cmux_ssh_auth_procfs_started" in + P_[1-9][0-9]*_0_0_0_0) ;; + *) continue ;; + esac + cmux_ssh_auth_procfs_expected_start="${cmux_ssh_auth_procfs_started#P_}" + cmux_ssh_auth_procfs_expected_start="${cmux_ssh_auth_procfs_expected_start%_0_0_0_0}" + if ! cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_procfs_pid"; then + [ -e "/proc/$cmux_ssh_auth_procfs_pid/stat" ] && cmux_ssh_auth_procfs_failed=1 + continue + fi + if [ "$cmux_ssh_auth_proc_parent" != "$cmux_ssh_auth_procfs_parent" ] || + [ "$cmux_ssh_auth_proc_group" != "$cmux_ssh_auth_procfs_group" ] || + [ "$cmux_ssh_auth_proc_start" != "$cmux_ssh_auth_procfs_expected_start" ]; then + cmux_ssh_auth_procfs_failed=1 + continue + fi + case "$cmux_ssh_auth_proc_state" in + Z) continue ;; + esac + case "$cmux_ssh_auth_procfs_signal_name" in + STOP) + case "$cmux_ssh_auth_proc_state" in + T) + [ "$cmux_ssh_auth_procfs_original_state" = T ] && + printf '%s\n' "$cmux_ssh_auth_procfs_depth $cmux_ssh_auth_procfs_pid $cmux_ssh_auth_procfs_parent $cmux_ssh_auth_procfs_group $cmux_ssh_auth_procfs_original_state $cmux_ssh_auth_procfs_started" >> "$cmux_ssh_auth_procfs_signal_output" + continue + ;; + esac + if /bin/kill -STOP "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1; then + printf '%s\n' "$cmux_ssh_auth_procfs_depth $cmux_ssh_auth_procfs_pid $cmux_ssh_auth_procfs_parent $cmux_ssh_auth_procfs_group $cmux_ssh_auth_procfs_original_state $cmux_ssh_auth_procfs_started" >> "$cmux_ssh_auth_procfs_signal_output" + else + cmux_ssh_auth_procfs_failed=1 + fi + ;; + CONT) + [ "$cmux_ssh_auth_procfs_original_state" = T ] && continue + if ! /bin/kill -CONT "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1; then + if cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_procfs_pid" && + [ "$cmux_ssh_auth_proc_parent" = "$cmux_ssh_auth_procfs_parent" ] && + [ "$cmux_ssh_auth_proc_group" = "$cmux_ssh_auth_procfs_group" ] && + [ "$cmux_ssh_auth_proc_start" = "$cmux_ssh_auth_procfs_expected_start" ] && + [ "$cmux_ssh_auth_proc_state" = T ]; then + cmux_ssh_auth_procfs_failed=1 + fi + fi + ;; + TERM) + [ "$cmux_ssh_auth_proc_state" = T ] || continue + cmux_ssh_auth_procfs_term_status=0 + cmux_ssh_auth_procfs_cont_status=0 + /bin/kill -TERM "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1 || cmux_ssh_auth_procfs_term_status=$? + /bin/kill -CONT "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1 || cmux_ssh_auth_procfs_cont_status=$? + if [ "$cmux_ssh_auth_procfs_term_status" -ne 0 ] || + [ "$cmux_ssh_auth_procfs_cont_status" -ne 0 ]; then + if cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_procfs_pid" && + [ "$cmux_ssh_auth_proc_parent" = "$cmux_ssh_auth_procfs_parent" ] && + [ "$cmux_ssh_auth_proc_group" = "$cmux_ssh_auth_procfs_group" ] && + [ "$cmux_ssh_auth_proc_start" = "$cmux_ssh_auth_procfs_expected_start" ] && + [ "$cmux_ssh_auth_proc_state" = T ]; then + cmux_ssh_auth_procfs_failed=1 + fi + fi + ;; + KILL) + [ "$cmux_ssh_auth_proc_state" = T ] || continue + if ! /bin/kill -KILL "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1; then + if cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_procfs_pid" && + [ "$cmux_ssh_auth_proc_parent" = "$cmux_ssh_auth_procfs_parent" ] && + [ "$cmux_ssh_auth_proc_group" = "$cmux_ssh_auth_procfs_group" ] && + [ "$cmux_ssh_auth_proc_start" = "$cmux_ssh_auth_procfs_expected_start" ] && + [ "$cmux_ssh_auth_proc_state" = T ]; then + cmux_ssh_auth_procfs_failed=1 + fi + fi + ;; + esac + done < "$cmux_ssh_auth_procfs_signal_input" + return "$cmux_ssh_auth_procfs_failed" + } + cmux_ssh_auth_signal_darwin_perl_batch() { cmux_ssh_auth_fallback_signal_name="$1" cmux_ssh_auth_fallback_signal_input="$2" cmux_ssh_auth_fallback_signal_output="${3:-/dev/null}" @@ -1403,117 +1563,29 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_ssh_auth_fallback_signal_input" "$cmux_ssh_auth_fallback_signal_output" \ >/dev/null 2>&1 cmux_ssh_auth_fallback_status=$? - if [ "$cmux_ssh_auth_fallback_status" -eq 0 ]; then - return 0 - fi + return "$cmux_ssh_auth_fallback_status" fi - # Keep a final recovery path even on stripped systems without - # Perl. The candidate came from a fresh kernel snapshot, and this - # check repeats its parent, group, and state fence before each - # standard kill. It is weaker than the pid-version path, so it is - # used only after both exact backends fail. - : > "$cmux_ssh_auth_fallback_signal_output" || return 1 - cmux_ssh_auth_fallback_failed=0 - while IFS=' ' read -r cmux_ssh_auth_fallback_depth cmux_ssh_auth_fallback_pid \ - cmux_ssh_auth_fallback_parent cmux_ssh_auth_fallback_group \ - cmux_ssh_auth_fallback_original_state cmux_ssh_auth_fallback_started; do - case "$cmux_ssh_auth_fallback_depth:$cmux_ssh_auth_fallback_pid:$cmux_ssh_auth_fallback_parent:$cmux_ssh_auth_fallback_group" in - ''|*[!0-9:]*|*:|*:) continue ;; - esac - case "$cmux_ssh_auth_fallback_started" in - K_[0-9]*_[0-9]*_0_0) ;; - *) continue ;; - esac - cmux_ssh_auth_fallback_current=$( - /bin/ps -o ppid= -o pgid= -o state= -p "$cmux_ssh_auth_fallback_pid" \ - 2>/dev/null || true - ) - set -- $cmux_ssh_auth_fallback_current - [ "$#" -ge 3 ] || continue - cmux_ssh_auth_fallback_observed_parent="$1" - cmux_ssh_auth_fallback_observed_group="$2" - cmux_ssh_auth_fallback_observed_state="$3" - [ "$cmux_ssh_auth_fallback_observed_parent" = "$cmux_ssh_auth_fallback_parent" ] || continue - [ "$cmux_ssh_auth_fallback_observed_group" = "$cmux_ssh_auth_fallback_group" ] || continue - case "$cmux_ssh_auth_fallback_observed_state" in *Z*) continue ;; esac - case "$cmux_ssh_auth_fallback_signal_name" in - STOP) - case "$cmux_ssh_auth_fallback_observed_state" in - *T*) - [ "$cmux_ssh_auth_fallback_original_state" = T ] && - printf '%s\n' "$cmux_ssh_auth_fallback_depth $cmux_ssh_auth_fallback_pid $cmux_ssh_auth_fallback_parent $cmux_ssh_auth_fallback_group $cmux_ssh_auth_fallback_original_state $cmux_ssh_auth_fallback_started" >> "$cmux_ssh_auth_fallback_signal_output" - continue - ;; - esac - if /bin/kill -STOP "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1; then - printf '%s\n' "$cmux_ssh_auth_fallback_depth $cmux_ssh_auth_fallback_pid $cmux_ssh_auth_fallback_parent $cmux_ssh_auth_fallback_group $cmux_ssh_auth_fallback_original_state $cmux_ssh_auth_fallback_started" >> "$cmux_ssh_auth_fallback_signal_output" - else - cmux_ssh_auth_fallback_failed=1 - fi - ;; - CONT) - [ "$cmux_ssh_auth_fallback_original_state" = T ] && continue - if ! /bin/kill -CONT "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1; then - cmux_ssh_auth_fallback_current=$( - /bin/ps -o ppid= -o pgid= -o state= -p "$cmux_ssh_auth_fallback_pid" \ - 2>/dev/null || true - ) - set -- $cmux_ssh_auth_fallback_current - if [ "$#" -ge 3 ] && [ "$1" = "$cmux_ssh_auth_fallback_parent" ] && - [ "$2" = "$cmux_ssh_auth_fallback_group" ]; then - case "$3" in *T*) cmux_ssh_auth_fallback_failed=1 ;; esac - fi - fi - ;; - TERM) - case "$cmux_ssh_auth_fallback_observed_state" in *T*) ;; *) continue ;; esac - cmux_ssh_auth_fallback_term_status=0 - cmux_ssh_auth_fallback_cont_status=0 - /bin/kill -TERM "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || cmux_ssh_auth_fallback_term_status=$? - /bin/kill -CONT "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || cmux_ssh_auth_fallback_cont_status=$? - if [ "$cmux_ssh_auth_fallback_term_status" -ne 0 ] || - [ "$cmux_ssh_auth_fallback_cont_status" -ne 0 ]; then - cmux_ssh_auth_fallback_current=$( - /bin/ps -o ppid= -o pgid= -o state= -p "$cmux_ssh_auth_fallback_pid" \ - 2>/dev/null || true - ) - set -- $cmux_ssh_auth_fallback_current - if [ "$#" -ge 3 ] && [ "$1" = "$cmux_ssh_auth_fallback_parent" ] && - [ "$2" = "$cmux_ssh_auth_fallback_group" ]; then - case "$3" in *T*) cmux_ssh_auth_fallback_failed=1 ;; esac - fi - fi - ;; - KILL) - case "$cmux_ssh_auth_fallback_observed_state" in *T*) ;; *) continue ;; esac - if ! /bin/kill -KILL "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1; then - cmux_ssh_auth_fallback_current=$( - /bin/ps -o ppid= -o pgid= -o state= -p "$cmux_ssh_auth_fallback_pid" \ - 2>/dev/null || true - ) - set -- $cmux_ssh_auth_fallback_current - if [ "$#" -ge 3 ] && [ "$1" = "$cmux_ssh_auth_fallback_parent" ] && - [ "$2" = "$cmux_ssh_auth_fallback_group" ]; then - case "$3" in *T*) cmux_ssh_auth_fallback_failed=1 ;; esac - fi - fi - ;; - esac - done < "$cmux_ssh_auth_fallback_signal_input" - return "$cmux_ssh_auth_fallback_failed" + return 1 } - cmux_ssh_auth_signal_verified_batch() { cmux_ssh_auth_signal_name="$1" cmux_ssh_auth_signal_input="$2" cmux_ssh_auth_signal_output="${3:-/dev/null}" cmux_ssh_auth_signal_filter_stopped="${4:-1}" if [ "$cmux_ssh_auth_signal_backend" != darwin ]; then - cmux_ssh_auth_signal_portable_batch \ - "$cmux_ssh_auth_signal_name" \ - "$cmux_ssh_auth_signal_input" \ - "$cmux_ssh_auth_signal_output" \ - "$cmux_ssh_auth_signal_filter_stopped" + if [ -n "$cmux_ssh_auth_perl_command" ]; then + cmux_ssh_auth_signal_portable_batch \ + "$cmux_ssh_auth_signal_name" \ + "$cmux_ssh_auth_signal_input" \ + "$cmux_ssh_auth_signal_output" \ + "$cmux_ssh_auth_signal_filter_stopped" + else + cmux_ssh_auth_signal_procfs_batch \ + "$cmux_ssh_auth_signal_name" \ + "$cmux_ssh_auth_signal_input" \ + "$cmux_ssh_auth_signal_output" \ + "$cmux_ssh_auth_signal_filter_stopped" + fi cmux_ssh_auth_signal_status=$? if [ "$cmux_ssh_auth_signal_status" -ne 0 ]; then cmux_ssh_auth_cleanup_needs_root_abort=1 @@ -1693,7 +1765,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { "$cmux_ssh_auth_signal_output" >/dev/null 2>&1 cmux_ssh_auth_signal_status=$? if [ "$cmux_ssh_auth_signal_status" -ne 0 ]; then - cmux_ssh_auth_signal_darwin_fallback_batch \ + cmux_ssh_auth_signal_darwin_perl_batch \ "$cmux_ssh_auth_signal_name" "$cmux_ssh_auth_signal_input" \ "$cmux_ssh_auth_signal_output" cmux_ssh_auth_signal_status=$? From 8c639004df96d57d4a19141c9449a5cb1cf2245d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:54:51 -0700 Subject: [PATCH 60/61] fix: revalidate SSH root before each fallback signal --- ...HForegroundAuthenticationRetryPolicy.swift | 92 +++++++++++-------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index 52b2f2a1a2a0..ed8d83b92d0b 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -191,14 +191,24 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { [1-9][0-9]*) ;; *) return 1 ;; esac - cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_fallback_pid" || return 1 - [ "$cmux_ssh_auth_proc_parent" = "$cmux_ssh_auth_fallback_parent" ] || return 1 - [ "$cmux_ssh_auth_proc_group" = "$cmux_ssh_auth_fallback_group" ] || return 1 - [ "$cmux_ssh_auth_proc_start" = "$cmux_ssh_auth_fallback_start" ] || return 1 - case "$cmux_ssh_auth_proc_state" in Z) return 0 ;; esac - /bin/kill -CONT "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || true - /bin/kill -TERM "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || true - /bin/kill -KILL "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1 || true + for cmux_ssh_auth_fallback_signal in CONT TERM KILL; do + if ! cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_fallback_pid"; then + [ -e "/proc/$cmux_ssh_auth_fallback_pid/stat" ] && return 1 + return 0 + fi + [ "$cmux_ssh_auth_proc_parent" = "$cmux_ssh_auth_fallback_parent" ] || return 1 + [ "$cmux_ssh_auth_proc_group" = "$cmux_ssh_auth_fallback_group" ] || return 1 + [ "$cmux_ssh_auth_proc_start" = "$cmux_ssh_auth_fallback_start" ] || return 1 + case "$cmux_ssh_auth_proc_state" in Z) return 0 ;; esac + if ! kill "-$cmux_ssh_auth_fallback_signal" \ + "$cmux_ssh_auth_fallback_pid" >/dev/null 2>&1; then + if ! cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_fallback_pid"; then + [ -e "/proc/$cmux_ssh_auth_fallback_pid/stat" ] && return 1 + return 0 + fi + return 1 + fi + done } cmux_ssh_auth_force_root_darwin_perl_fallback() { [ -x /usr/bin/perl ] || return 1 @@ -213,33 +223,43 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { $group =~ /\A[1-9][0-9]*\z/ && $seconds =~ /\A[1-9][0-9]*\z/ && $microseconds =~ /\A[0-9]+\z/ && $microseconds < 1_000_000; my $pid_number = int($pid); - my $matched = 0; - for my $size (136, 184) { - my $buffer = "\0" x $size; - my $written = syscall(336, 2, $pid_number, 3, 0, $buffer, $size); - next unless defined $written && $written == $size; - my ($group_offset, $seconds_offset, $microseconds_offset) = - $size == 136 ? (100, 120, 128) : (148, 168, 176); - my $status = unpack("L<", substr($buffer, 4, 4)); - my $observed_pid = unpack("L<", substr($buffer, 12, 4)); - my $observed_parent = unpack("L<", substr($buffer, 16, 4)); - my $observed_group = unpack("L<", substr($buffer, $group_offset, 4)); - my $observed_seconds = unpack("Q<", substr($buffer, $seconds_offset, 8)); - my $observed_microseconds = unpack("Q<", substr($buffer, $microseconds_offset, 8)); - if ($observed_pid == $pid_number && $observed_parent == $parent && - $observed_group == $group && $status != 5 && - $observed_seconds == $seconds && $observed_microseconds == $microseconds) { - $matched = 1; - last; + sub read_identity { + for my $size (136, 184) { + my $buffer = "\0" x $size; + my $written = syscall(336, 2, $pid_number, 3, 0, $buffer, $size); + next unless defined $written && $written == $size; + my ($group_offset, $seconds_offset, $microseconds_offset) = + $size == 136 ? (100, 120, 128) : (148, 168, 176); + return [ + unpack("L<", substr($buffer, 12, 4)), + unpack("L<", substr($buffer, 16, 4)), + unpack("L<", substr($buffer, $group_offset, 4)), + unpack("L<", substr($buffer, 4, 4)), + unpack("Q<", substr($buffer, $seconds_offset, 8)), + unpack("Q<", substr($buffer, $microseconds_offset, 8)) + ]; } + return; + } + sub matches { + my ($identity) = @_; + return defined $identity && $identity->[0] == $pid_number && + $identity->[1] == $parent && $identity->[2] == $group && + $identity->[4] == $seconds && $identity->[5] == $microseconds; } - exit 1 unless $matched; - my $failed = 0; for my $signal_number (18, 15, 9) { - next if kill($signal_number, $pid_number); - $failed = 1 if kill(0, $pid_number); + my $before = read_identity(); + exit 1 unless defined $before; + exit 0 if $before->[3] == 5; + exit 1 unless matches($before); + if (!kill($signal_number, $pid_number)) { + my $after = read_identity(); + exit 1 unless defined $after; + exit 0 if $after->[3] == 5; + exit 1; + } } - exit $failed; + exit 0; ' "$1" >/dev/null 2>&1 } cmux_ssh_auth_capture_root_termination_identity() { @@ -1413,7 +1433,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { continue ;; esac - if /bin/kill -STOP "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1; then + if kill -STOP "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1; then printf '%s\n' "$cmux_ssh_auth_procfs_depth $cmux_ssh_auth_procfs_pid $cmux_ssh_auth_procfs_parent $cmux_ssh_auth_procfs_group $cmux_ssh_auth_procfs_original_state $cmux_ssh_auth_procfs_started" >> "$cmux_ssh_auth_procfs_signal_output" else cmux_ssh_auth_procfs_failed=1 @@ -1421,7 +1441,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { ;; CONT) [ "$cmux_ssh_auth_procfs_original_state" = T ] && continue - if ! /bin/kill -CONT "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1; then + if ! kill -CONT "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1; then if cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_procfs_pid" && [ "$cmux_ssh_auth_proc_parent" = "$cmux_ssh_auth_procfs_parent" ] && [ "$cmux_ssh_auth_proc_group" = "$cmux_ssh_auth_procfs_group" ] && @@ -1435,8 +1455,8 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { [ "$cmux_ssh_auth_proc_state" = T ] || continue cmux_ssh_auth_procfs_term_status=0 cmux_ssh_auth_procfs_cont_status=0 - /bin/kill -TERM "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1 || cmux_ssh_auth_procfs_term_status=$? - /bin/kill -CONT "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1 || cmux_ssh_auth_procfs_cont_status=$? + kill -TERM "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1 || cmux_ssh_auth_procfs_term_status=$? + kill -CONT "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1 || cmux_ssh_auth_procfs_cont_status=$? if [ "$cmux_ssh_auth_procfs_term_status" -ne 0 ] || [ "$cmux_ssh_auth_procfs_cont_status" -ne 0 ]; then if cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_procfs_pid" && @@ -1450,7 +1470,7 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { ;; KILL) [ "$cmux_ssh_auth_proc_state" = T ] || continue - if ! /bin/kill -KILL "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1; then + if ! kill -KILL "$cmux_ssh_auth_procfs_pid" >/dev/null 2>&1; then if cmux_ssh_auth_read_proc_stat "$cmux_ssh_auth_procfs_pid" && [ "$cmux_ssh_auth_proc_parent" = "$cmux_ssh_auth_procfs_parent" ] && [ "$cmux_ssh_auth_proc_group" = "$cmux_ssh_auth_procfs_group" ] && From 4526b728450ef0cf2dac1994f02ceb80269b2e1b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:01:08 -0700 Subject: [PATCH 61/61] fix: revalidate Darwin fallback signals --- .../SSHForegroundAuthenticationRetryPolicy.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift index ed8d83b92d0b..753fc02d64be 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHForegroundAuthenticationRetryPolicy.swift @@ -1537,7 +1537,13 @@ public struct SSHForegroundAuthenticationRetryPolicy: Sendable { my $before = read_identity($pid); next unless matches($before, $pid, $group, int($seconds), int($microseconds)); next if $before->[3] == 5; - my $send = sub { kill($signals{$_[0]}, $pid) ? 1 : 0 }; + my $send = sub { + my ($signal) = @_; + my $current = read_identity($pid); + return 0 unless matches($current, $pid, $group, int($seconds), int($microseconds)); + return 0 if $current->[3] == 5; + return kill($signals{$signal}, $pid) ? 1 : 0; + }; if ($signal_name eq "STOP") { if ($before->[3] == 4) { print {$output} "$line\n" if $original_state eq "T";