From c6a9d1846adfb2f01ee8d7d53119e562ea01b988 Mon Sep 17 00:00:00 2001 From: Rick Morgans Date: Sat, 14 Mar 2026 22:50:34 +1030 Subject: [PATCH 1/6] fix: retry partial writes in packet and push paths Extract write_all() and use it everywhere a socket write must be complete-or-fail. Fixes spurious disconnects under memory pressure or signal interruption that returns a short count. Co-Authored-By: Claude Opus 4.6 (1M context) --- attach.c | 79 +++++++++++++++++++++++++++----------------------------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/attach.c b/attach.c index 2de74ac..8630e8d 100644 --- a/attach.c +++ b/attach.c @@ -27,8 +27,9 @@ char const *clear_csi_data(void) return "\033[999H\r\n"; } -/* Write buf to fd handling partial writes. Exit on failure. */ -void write_buf_or_fail(int fd, const void *buf, size_t count) +/* Write all of buf to fd, retrying on short writes and EINTR. +** Returns 0 on success, -1 on failure (errno is set). */ +static int write_all(int fd, const void *buf, size_t count) { while (count != 0) { ssize_t ret = write(fd, buf, count); @@ -36,49 +37,49 @@ void write_buf_or_fail(int fd, const void *buf, size_t count) if (ret >= 0) { buf = (const char *)buf + ret; count -= ret; - } else if (ret < 0 && errno == EINTR) + } else if (errno == EINTR) continue; - else { - if (session_start) { - char age[32]; - session_age(age, sizeof(age)); - printf - ("%s[%s: session '%s' write failed after %s]\r\n", - clear_csi_data(), progname, - session_shortname(), age); - } else { - printf("%s[%s: write failed]\r\n", - clear_csi_data(), progname); - } - exit(1); + else + return -1; + } + return 0; +} + +/* Write buf to fd handling partial writes. Exit on failure. */ +void write_buf_or_fail(int fd, const void *buf, size_t count) +{ + if (write_all(fd, buf, count) < 0) { + if (session_start) { + char age[32]; + session_age(age, sizeof(age)); + printf + ("%s[%s: session '%s' write failed after %s]\r\n", + clear_csi_data(), progname, + session_shortname(), age); + } else { + printf("%s[%s: write failed]\r\n", + clear_csi_data(), progname); } + exit(1); } } /* Write pkt to fd. Exit on failure. */ void write_packet_or_fail(int fd, const struct packet *pkt) { - while (1) { - ssize_t ret = write(fd, pkt, sizeof(struct packet)); - - if (ret == sizeof(struct packet)) - return; - else if (ret < 0 && errno == EINTR) - continue; - else { - if (session_start) { - char age[32]; - session_age(age, sizeof(age)); - printf - ("%s[%s: session '%s' write failed after %s]\r\n", - clear_csi_data(), progname, - session_shortname(), age); - } else { - printf("%s[%s: write failed]\r\n", - clear_csi_data(), progname); - } - exit(1); + if (write_all(fd, pkt, sizeof(struct packet)) < 0) { + if (session_start) { + char age[32]; + session_age(age, sizeof(age)); + printf + ("%s[%s: session '%s' write failed after %s]\r\n", + clear_csi_data(), progname, + session_shortname(), age); + } else { + printf("%s[%s: write failed]\r\n", + clear_csi_data(), progname); } + exit(1); } } @@ -497,11 +498,7 @@ int push_main() } pkt.len = len; - len = write(s, &pkt, sizeof(struct packet)); - if (len != sizeof(struct packet)) { - if (len >= 0) - errno = EPIPE; - + if (write_all(s, &pkt, sizeof(struct packet)) < 0) { printf("%s: %s: %s\n", progname, sockname, strerror(errno)); return 1; From 851934a7678f4c6cada8f8a5c3adc8b93355a57e Mon Sep 17 00:00:00 2001 From: Rick Morgans Date: Sat, 14 Mar 2026 23:05:18 +1030 Subject: [PATCH 2/6] fix: use write_all in send_kill (missed in initial fix) send_kill() still used a bare write() for the kill packet. Apply the same write_all() retry loop as push and attach paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- attach.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/attach.c b/attach.c index 8630e8d..f49ac9a 100644 --- a/attach.c +++ b/attach.c @@ -518,9 +518,9 @@ static int send_kill(int sig) memset(&pkt, 0, sizeof(pkt)); pkt.type = MSG_KILL; pkt.len = (unsigned char)sig; - ret = write(s, &pkt, sizeof(pkt)); + ret = write_all(s, &pkt, sizeof(pkt)); close(s); - return (ret == sizeof(pkt)) ? 0 : -1; + return ret; } static int session_gone(void) From 6b8c395e35c6c5d72b3293674f17c71ffd72ceb7 Mon Sep 17 00:00:00 2001 From: Rick Morgans Date: Sat, 14 Mar 2026 23:15:27 +1030 Subject: [PATCH 3/6] fix: treat write()=0 as failure, add regression tests - write_all: return -1 with errno=EIO when write() returns 0 (no progress), preventing an infinite retry loop - Include fault injection tests (preload_short_write.c) that force short socket writes to deterministically verify the retry logic in push and kill paths Co-Authored-By: Claude Opus 4.6 (1M context) --- attach.c | 10 +++++-- tests/preload_short_write.c | 58 +++++++++++++++++++++++++++++++++++++ tests/test.sh | 57 ++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 tests/preload_short_write.c diff --git a/attach.c b/attach.c index f49ac9a..48fcf70 100644 --- a/attach.c +++ b/attach.c @@ -34,13 +34,17 @@ static int write_all(int fd, const void *buf, size_t count) while (count != 0) { ssize_t ret = write(fd, buf, count); - if (ret >= 0) { + if (ret > 0) { buf = (const char *)buf + ret; count -= ret; - } else if (errno == EINTR) + } else if (ret < 0 && errno == EINTR) continue; - else + else { + /* ret == 0 (no progress) or ret < 0 (real error) */ + if (ret == 0) + errno = EIO; return -1; + } } return 0; } diff --git a/tests/preload_short_write.c b/tests/preload_short_write.c new file mode 100644 index 0000000..11828d3 --- /dev/null +++ b/tests/preload_short_write.c @@ -0,0 +1,58 @@ +#include +#include +#include +#include +#include + +static int did_inject; + +static ssize_t real_write(int fd, const void *buf, size_t count) +{ + return syscall(SYS_write, fd, buf, count); +} + +static int should_inject(int fd, size_t count) +{ + struct stat st; + + if (did_inject || count <= 1) + return 0; + if (!getenv("ATCH_FAULT_SHORT_WRITE_ONCE")) + return 0; + if (fstat(fd, &st) < 0) + return 0; + return S_ISSOCK(st.st_mode); +} + +static ssize_t short_write_impl(int fd, const void *buf, size_t count) +{ + if (should_inject(fd, count)) { + did_inject = 1; + return real_write(fd, buf, 1); + } + return real_write(fd, buf, count); +} + +#ifdef __APPLE__ +#define DYLD_INTERPOSE(_replacement, _replacee) \ + __attribute__((used)) static struct { \ + const void *replacement; \ + const void *replacee; \ + } _interpose_##_replacee \ + __attribute__((section("__DATA,__interpose"))) = { \ + (const void *)(unsigned long)&_replacement, \ + (const void *)(unsigned long)&_replacee \ + } + +ssize_t interposed_write(int fd, const void *buf, size_t count) +{ + return short_write_impl(fd, buf, count); +} + +DYLD_INTERPOSE(interposed_write, write); +#else +ssize_t write(int fd, const void *buf, size_t count) +{ + return short_write_impl(fd, buf, count); +} +#endif diff --git a/tests/test.sh b/tests/test.sh index 0c954c0..64b7bc7 100644 --- a/tests/test.sh +++ b/tests/test.sh @@ -720,6 +720,63 @@ assert_contains "no args: shows Usage:" "Usage:" "$out" run "$ATCH" --help assert_contains "help: shows tail command" "tail" "$out" +# ── 23. fault injection: short socket writes are retried ─────────────────── +# Force the first packet write to a socket to complete with 1 byte. +# Verifies write_all() retries correctly instead of treating short writes +# as fatal. + +TESTS_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +OS_NAME=$(uname -s) + +FAULT_LIB= +build_short_write_injector() { + [ -n "$FAULT_LIB" ] && return 0 + case "$OS_NAME" in + Darwin) + FAULT_LIB="$TESTDIR/libshortwrite.dylib" + cc -dynamiclib -O2 -Wall -o "$FAULT_LIB" \ + "$TESTS_DIR/preload_short_write.c" >/dev/null 2>&1 ;; + *) + FAULT_LIB="$TESTDIR/libshortwrite.so" + cc -shared -fPIC -O2 -Wall -o "$FAULT_LIB" \ + "$TESTS_DIR/preload_short_write.c" -ldl >/dev/null 2>&1 ;; + esac +} + +with_short_socket_write() { + build_short_write_injector || return 1 + case "$OS_NAME" in + Darwin) + env DYLD_INSERT_LIBRARIES="$FAULT_LIB" \ + DYLD_FORCE_FLAT_NAMESPACE=1 \ + ATCH_FAULT_SHORT_WRITE_ONCE=1 "$@" ;; + *) + env LD_PRELOAD="$FAULT_LIB" \ + ATCH_FAULT_SHORT_WRITE_ONCE=1 "$@" ;; + esac +} + +"$ATCH" start short-push sh -c 'cat' +wait_socket short-push +out=$(printf 'short-write-marker\n' | with_short_socket_write \ + "$ATCH" push short-push 2>&1) +prc=$? +assert_exit "fault: push retries short socket write" 0 "$prc" +sleep 0.2 +assert_contains "fault: push data reaches session after short write" \ + "short-write-marker" "$(cat "$HOME/.cache/atch/short-push.log" 2>/dev/null)" +tidy short-push + +"$ATCH" start short-kill sleep 999 +wait_socket short-kill +out=$(with_short_socket_write "$ATCH" kill short-kill 2>&1) +krc=$? +assert_exit "fault: kill retries short socket write" 0 "$krc" +run "$ATCH" list +assert_not_contains "fault: session is gone after short-write kill" \ + "short-kill" "$out" +"$ATCH" kill -f short-kill >/dev/null 2>&1 || true + # ── summary ────────────────────────────────────────────────────────────────── printf "\n1..%d\n" "$T" From 0f0f734d86748a119a78c0a9726dbafb25b3ecd8 Mon Sep 17 00:00:00 2001 From: Rick Morgans Date: Sun, 15 Mar 2026 11:09:46 +1030 Subject: [PATCH 4/6] fix: make signal handlers async-signal-safe and handle blocked writes Replace printf/exit in signal handlers with volatile sig_atomic_t flags. Use sigaction() instead of signal() to control SA_RESTART explicitly: SA_RESTART for SIGWINCH (benign), no SA_RESTART for fatal signals so select() returns EINTR promptly. Handle EINTR in read paths. write_all() stops retrying EINTR when die_signal is set. When stdout itself is wedged (blocked write to PTY), the deferred-signal exit path uses TCSANOW + _exit() to avoid hanging in printf or atexit handlers. Co-Authored-By: Claude Opus 4.6 (1M context) --- attach.c | 96 +++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 71 insertions(+), 25 deletions(-) diff --git a/attach.c b/attach.c index 48fcf70..55f183e 100644 --- a/attach.c +++ b/attach.c @@ -14,7 +14,9 @@ */ static struct termios cur_term; /* 1 if the window size changed */ -static int win_changed; +static volatile sig_atomic_t win_changed; +/* Non-zero if a fatal signal was received; stores the signal number. */ +static volatile sig_atomic_t die_signal; /* Socket creation time, used to compute session age in messages. */ time_t session_start; @@ -27,6 +29,29 @@ char const *clear_csi_data(void) return "\033[999H\r\n"; } +/* Exit promptly once the main thread notices a fatal signal. + * If terminal output itself is wedged, skip stdio entirely. */ +static void exit_for_deferred_signal(int can_print) +{ + int sig = die_signal; + char age[32]; + + if (!sig) + return; + if (!can_print) { + tcsetattr(0, TCSANOW, &orig_term); + _exit(1); + } + session_age(age, sizeof(age)); + if (sig == SIGHUP || sig == SIGINT) + printf("%s[%s: session '%s' detached after %s]\r\n", + clear_csi_data(), progname, session_shortname(), age); + else + printf("%s[%s: session '%s' got signal %d - exiting after %s]\r\n", + clear_csi_data(), progname, session_shortname(), sig, age); + exit(1); +} + /* Write all of buf to fd, retrying on short writes and EINTR. ** Returns 0 on success, -1 on failure (errno is set). */ static int write_all(int fd, const void *buf, size_t count) @@ -37,9 +62,11 @@ static int write_all(int fd, const void *buf, size_t count) if (ret > 0) { buf = (const char *)buf + ret; count -= ret; - } else if (ret < 0 && errno == EINTR) + } else if (ret < 0 && errno == EINTR) { + if (die_signal) + return -1; continue; - else { + } else { /* ret == 0 (no progress) or ret < 0 (real error) */ if (ret == 0) errno = EIO; @@ -53,6 +80,7 @@ static int write_all(int fd, const void *buf, size_t count) void write_buf_or_fail(int fd, const void *buf, size_t count) { if (write_all(fd, buf, count) < 0) { + exit_for_deferred_signal(fd != 1); if (session_start) { char age[32]; session_age(age, sizeof(age)); @@ -72,6 +100,7 @@ void write_buf_or_fail(int fd, const void *buf, size_t count) void write_packet_or_fail(int fd, const struct packet *pkt) { if (write_all(fd, pkt, sizeof(struct packet)) < 0) { + exit_for_deferred_signal(fd != 1); if (session_start) { char age[32]; session_age(age, sizeof(age)); @@ -154,26 +183,15 @@ void session_age(char *buf, size_t size) format_age(now > session_start ? now - session_start : 0, buf, size); } -/* Signal */ +/* Signal -- only set a flag; all non-trivial work happens in the main loop. */ static RETSIGTYPE die(int sig) { - char age[32]; - session_age(age, sizeof(age)); - /* Print a nice pretty message for some things. */ - if (sig == SIGHUP || sig == SIGINT) - printf("%s[%s: session '%s' detached after %s]\r\n", - clear_csi_data(), progname, session_shortname(), age); - else - printf - ("%s[%s: session '%s' got signal %d - exiting after %s]\r\n", - clear_csi_data(), progname, session_shortname(), sig, age); - exit(1); + die_signal = sig; } -/* Window size change. */ +/* Window size change -- only set a flag. */ static RETSIGTYPE win_change(ATTRIBUTE_UNUSED int sig) { - signal(SIGWINCH, win_change); win_changed = 1; } @@ -349,14 +367,32 @@ int attach_main(int noerror) /* Set a trap to restore the terminal when we die. */ atexit(restore_term); - /* Set some signals. */ - signal(SIGPIPE, SIG_IGN); - signal(SIGXFSZ, SIG_IGN); - signal(SIGHUP, die); - signal(SIGTERM, die); - signal(SIGINT, die); - signal(SIGQUIT, die); - signal(SIGWINCH, win_change); + /* Set some signals using sigaction to avoid SA_RESTART ambiguity. */ + { + struct sigaction sa_ign, sa_die, sa_winch; + + memset(&sa_ign, 0, sizeof(sa_ign)); + sa_ign.sa_handler = SIG_IGN; + sigemptyset(&sa_ign.sa_mask); + sigaction(SIGPIPE, &sa_ign, NULL); + sigaction(SIGXFSZ, &sa_ign, NULL); + + memset(&sa_die, 0, sizeof(sa_die)); + sa_die.sa_handler = die; + sigemptyset(&sa_die.sa_mask); + /* No SA_RESTART: let select() return EINTR so the loop + * notices die_signal promptly. */ + sigaction(SIGHUP, &sa_die, NULL); + sigaction(SIGTERM, &sa_die, NULL); + sigaction(SIGINT, &sa_die, NULL); + sigaction(SIGQUIT, &sa_die, NULL); + + memset(&sa_winch, 0, sizeof(sa_winch)); + sa_winch.sa_handler = win_change; + sigemptyset(&sa_winch.sa_mask); + sa_winch.sa_flags = SA_RESTART; /* benign — don't interrupt I/O */ + sigaction(SIGWINCH, &sa_winch, NULL); + } /* Set raw mode. */ cur_term.c_iflag &= @@ -401,10 +437,16 @@ int attach_main(int noerror) while (1) { int n; + exit_for_deferred_signal(1); + FD_ZERO(&readfds); FD_SET(0, &readfds); FD_SET(s, &readfds); n = select(s + 1, &readfds, NULL, NULL, NULL); + + /* Check for deferred fatal signal. */ + exit_for_deferred_signal(1); + if (n < 0 && errno != EINTR && errno != EAGAIN) { char age[32]; session_age(age, sizeof(age)); @@ -430,6 +472,8 @@ int attach_main(int noerror) } exit(0); } else if (len < 0) { + if (errno == EINTR) + continue; char age[32]; session_age(age, sizeof(age)); printf @@ -450,6 +494,8 @@ int attach_main(int noerror) memset(pkt.u.buf, 0, sizeof(pkt.u.buf)); len = read(0, pkt.u.buf, sizeof(pkt.u.buf)); + if (len < 0 && errno == EINTR) + continue; if (len <= 0) exit(1); From fea3204a2c794e1a1c2b560f90b774a5f19aca1f Mon Sep 17 00:00:00 2001 From: Rick Morgans Date: Sun, 15 Mar 2026 11:09:55 +1030 Subject: [PATCH 5/6] test: add forkpty-based signal safety integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C harness using forkpty() for exact PID targeting — no pkill races or heuristic process identification. Tests SIGWINCH survival, SIGTERM exit, SIGHUP exit, SIGTERM during blocked stdout write, and detach character. Wire harness into test.sh with TAP folding that preserves diagnostic lines for debuggability. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test.sh | 37 +++++ tests/test_signal.c | 381 +++++++++++++++++++++++++++++++++++++++++++ tests/test_signal.sh | 50 ++++++ 3 files changed, 468 insertions(+) create mode 100644 tests/test_signal.c create mode 100755 tests/test_signal.sh diff --git a/tests/test.sh b/tests/test.sh index 64b7bc7..1578859 100644 --- a/tests/test.sh +++ b/tests/test.sh @@ -777,6 +777,43 @@ assert_not_contains "fault: session is gone after short-write kill" \ "short-kill" "$out" "$ATCH" kill -f short-kill >/dev/null 2>&1 || true +# ── 24. signal safety (forkpty harness) ──────────────────────────────────── +# Builds and runs a C test binary that uses forkpty() to send signals +# to the exact atch attach PID. Skips gracefully if cc is unavailable. + +TESTS_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +SIGNAL_HARNESS="$TESTDIR/test_signal" + +if cc -o "$SIGNAL_HARNESS" "$TESTS_DIR/test_signal.c" -lutil 2>/dev/null; then + "$ATCH" start sig-harness sleep 9999 + wait_socket sig-harness + + sig_out=$("$SIGNAL_HARNESS" "$ATCH" sig-harness 2>&1) + + # Fold harness results into main TAP stream (avoid subshell pipe) + sig_tmpfile="$TESTDIR/sig_out.txt" + echo "$sig_out" > "$sig_tmpfile" + while IFS= read -r line; do + case "$line" in + ok\ *) + desc=$(echo "$line" | sed 's/^ok [0-9]* - //') + ok "signal: $desc" + ;; + not\ ok\ *) + desc=$(echo "$line" | sed 's/^not ok [0-9]* - //') + fail "signal: $desc" + ;; + "#"*) + printf "%s\n" "$line" + ;; + esac + done < "$sig_tmpfile" + + tidy sig-harness +else + ok "signal: SKIP — cc not available, cannot build forkpty harness" +fi + # ── summary ────────────────────────────────────────────────────────────────── printf "\n1..%d\n" "$T" diff --git a/tests/test_signal.c b/tests/test_signal.c new file mode 100644 index 0000000..813e56b --- /dev/null +++ b/tests/test_signal.c @@ -0,0 +1,381 @@ +/* + * test_signal.c — deterministic signal-safety tests for atch attach. + * + * Uses forkpty() to create a real PTY, execs atch attach in the child, + * and sends signals to the exact child PID from the parent. No pkill, + * no script, no heuristics. + * + * Build: cc -o test_signal tests/test_signal.c -lutil + * Usage: ./test_signal + * + * Requires a running atch session named "sig-test-session". + * The wrapper script tests/test_signal.sh handles setup/teardown. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__APPLE__) +#include +#else +#include +#endif + +static int pass_count = 0; +static int fail_count = 0; +static int test_num = 0; + +static void ok(const char *desc) +{ + test_num++; + pass_count++; + printf("ok %d - %s\n", test_num, desc); +} + +static void fail(const char *desc, const char *detail) +{ + test_num++; + fail_count++; + printf("not ok %d - %s\n", test_num, desc); + if (detail) + printf(" # %s\n", detail); +} + +/* Wait for child to exit within timeout_ms, draining master_fd to prevent + * the child from blocking on PTY writes (e.g. during session log replay). + * Pass master_fd=-1 if the master is already closed. + * Returns exit status or -1 on timeout. */ +static int wait_exit(pid_t pid, int timeout_ms, int master_fd) +{ + int elapsed = 0; + int status; + char drain[4096]; + + while (elapsed < timeout_ms) { + pid_t r = waitpid(pid, &status, WNOHANG); + if (r == pid) { + if (WIFEXITED(status)) + return WEXITSTATUS(status); + if (WIFSIGNALED(status)) + return 128 + WTERMSIG(status); + return -1; + } + /* Drain PTY output to prevent child blocking on write */ + if (master_fd >= 0) { + struct pollfd pfd = { .fd = master_fd, .events = POLLIN }; + if (poll(&pfd, 1, 0) > 0) + (void)read(master_fd, drain, sizeof(drain)); + } + usleep(10000); /* 10ms */ + elapsed += 10; + } + return -1; /* timed out */ +} + +/* Check if pid is still alive. */ +static int is_alive(pid_t pid) +{ + return kill(pid, 0) == 0; +} + +/* Read available data from fd into buf (non-blocking). */ +static ssize_t drain_fd(int fd, char *buf, size_t size, int timeout_ms) +{ + struct pollfd pfd = { .fd = fd, .events = POLLIN }; + ssize_t total = 0; + + while (total < (ssize_t)size - 1) { + int r = poll(&pfd, 1, timeout_ms); + if (r <= 0) + break; + ssize_t n = read(fd, buf + total, size - 1 - total); + if (n <= 0) + break; + total += n; + timeout_ms = 50; /* short timeout for subsequent reads */ + } + buf[total] = '\0'; + return total; +} + +/* + * Fork a child that execs atch attach . + * Returns child PID, sets *master_fd to the PTY master. + */ +static pid_t spawn_attach(const char *atch_bin, const char *session, + int *master_fd) +{ + int master; + pid_t pid = forkpty(&master, NULL, NULL, NULL); + + if (pid < 0) { + perror("forkpty"); + exit(1); + } + + if (pid == 0) { + /* child — exec atch attach */ + execl(atch_bin, atch_bin, "attach", session, (char *)NULL); + perror("execl"); + _exit(127); + } + + /* parent */ + *master_fd = master; + + /* Make master non-blocking for drain_fd */ + int flags = fcntl(master, F_GETFL); + if (flags >= 0) + fcntl(master, F_SETFL, flags | O_NONBLOCK); + + return pid; +} + +/* + * Test: SIGWINCH does not kill the attach process. + * After receiving SIGWINCH, the child must still be alive. + */ +static void test_sigwinch_survives(const char *atch_bin, const char *session) +{ + int master; + pid_t pid = spawn_attach(atch_bin, session, &master); + + /* Let attach settle */ + usleep(300000); + + if (!is_alive(pid)) { + fail("sigwinch: child alive before signal", "child died during attach"); + close(master); + return; + } + + /* Send SIGWINCH */ + kill(pid, SIGWINCH); + usleep(200000); + + if (is_alive(pid)) + ok("sigwinch: child survives SIGWINCH"); + else + fail("sigwinch: child survives SIGWINCH", "child died after SIGWINCH"); + + /* Send burst of SIGWINCH */ + for (int i = 0; i < 10; i++) { + kill(pid, SIGWINCH); + usleep(10000); + } + usleep(200000); + + if (is_alive(pid)) + ok("sigwinch: child survives SIGWINCH burst (10x)"); + else + fail("sigwinch: child survives SIGWINCH burst", "child died during burst"); + + /* Clean up: send SIGTERM to exit */ + kill(pid, SIGTERM); + wait_exit(pid, 2000, master); + close(master); +} + +/* + * Test: SIGTERM causes prompt exit (no deadlock). + */ +static void test_sigterm_exits(const char *atch_bin, const char *session) +{ + int master; + pid_t pid = spawn_attach(atch_bin, session, &master); + + usleep(300000); + + if (!is_alive(pid)) { + fail("sigterm: child alive before signal", "child died during attach"); + close(master); + return; + } + + kill(pid, SIGTERM); + + /* Wait with PTY master still open — child must exit from the signal + * handler, not from EOF on the terminal. Parent drains the PTY to + * prevent the child blocking on replay writes. */ + int status = wait_exit(pid, 3000, master); + close(master); + + if (status == -1) { + fail("sigterm: child exits within 3s (master still open)", + "child hung (possible deadlock)"); + kill(pid, SIGKILL); + waitpid(pid, NULL, 0); + } else { + ok("sigterm: child exits promptly after SIGTERM"); + } + + if (!is_alive(pid)) + ok("sigterm: child is dead after SIGTERM"); + else + fail("sigterm: child is dead after SIGTERM", "child still alive"); +} + +/* + * Test: SIGHUP causes prompt exit (simulates SSH disconnect). + */ +static void test_sighup_exits(const char *atch_bin, const char *session) +{ + int master; + pid_t pid = spawn_attach(atch_bin, session, &master); + + usleep(300000); + + if (!is_alive(pid)) { + fail("sighup: child alive before signal", "child died during attach"); + close(master); + return; + } + + kill(pid, SIGHUP); + + /* Wait with PTY master still open — child must exit from the signal + * handler, not from EOF on the terminal. Parent drains the PTY to + * prevent the child blocking on replay writes. */ + int status = wait_exit(pid, 3000, master); + close(master); + + if (status == -1) { + fail("sighup: child exits within 3s (master still open)", + "child hung (possible deadlock)"); + kill(pid, SIGKILL); + waitpid(pid, NULL, 0); + } else { + ok("sighup: child exits promptly after SIGHUP"); + } + + if (!is_alive(pid)) + ok("sighup: child is dead after SIGHUP"); + else + fail("sighup: child is dead after SIGHUP", "child still alive"); +} + +/* + * Test: SIGTERM still exits promptly when attach is blocked writing to + * its own PTY. The parent intentionally does not drain the PTY master. + */ +static void test_sigterm_exits_while_stdout_blocked(const char *atch_bin, + const char *session) +{ + int master; + pid_t pid = spawn_attach(atch_bin, session, &master); + + /* Give the noisy session time to fill the PTY and block the child. */ + usleep(1000000); + + if (!is_alive(pid)) { + fail("sigterm: child alive before blocked-write signal", + "child died during noisy attach"); + close(master); + return; + } + + kill(pid, SIGTERM); + + /* Keep the PTY master open but undrained. If attach retries EINTR + * forever in write_all(), this wait will time out. */ + int status = wait_exit(pid, 3000, -1); + close(master); + + if (status == -1) { + fail("sigterm: child exits within 3s while stdout blocked", + "child hung in blocked write"); + kill(pid, SIGKILL); + waitpid(pid, NULL, 0); + } else { + ok("sigterm: child exits promptly while stdout blocked"); + } + + if (!is_alive(pid)) + ok("sigterm: child is dead after blocked-write SIGTERM"); + else + fail("sigterm: child is dead after blocked-write SIGTERM", + "child still alive"); +} + +/* + * Test: detach character (^\, 0x1c) causes clean detach. + * The child should exit, and the session should still be running. + */ +static void test_detach_char(const char *atch_bin, const char *session) +{ + int master; + pid_t pid = spawn_attach(atch_bin, session, &master); + + usleep(300000); + + if (!is_alive(pid)) { + fail("detach: child alive before detach char", "child died during attach"); + close(master); + return; + } + + /* Send detach character: ^\ (0x1c) */ + char detach = 0x1c; + if (write(master, &detach, 1) < 0) { + fail("detach: write detach char to PTY", strerror(errno)); + close(master); + return; + } + + /* Give atch time to process the detach and exit */ + usleep(500000); + + /* Read any output from PTY before closing */ + char buf[4096]; + drain_fd(master, buf, sizeof(buf), 200); + close(master); + + int status = wait_exit(pid, 3000, -1); + if (status == -1) { + fail("detach: child exits after detach char", "child hung"); + kill(pid, SIGKILL); + waitpid(pid, NULL, 0); + } else { + ok("detach: child exits after detach char"); + } + + if (!is_alive(pid)) + ok("detach: child is dead after detach"); + else + fail("detach: child is dead after detach", "child still alive"); +} + +int main(int argc, char **argv) +{ + if (argc < 2) { + fprintf(stderr, + "Usage: %s [session-name] [noisy-session]\n", + argv[0]); + return 1; + } + + const char *atch_bin = argv[1]; + const char *session = argc > 2 ? argv[2] : "sig-test-session"; + const char *noisy_session = argc > 3 ? argv[3] : "sig-noisy-session"; + + printf("TAP version 13\n"); + + test_sigwinch_survives(atch_bin, session); + test_sigterm_exits(atch_bin, session); + test_sighup_exits(atch_bin, session); + test_sigterm_exits_while_stdout_blocked(atch_bin, noisy_session); + test_detach_char(atch_bin, session); + + printf("\n1..%d\n", test_num); + printf("# %d passed, %d failed\n", pass_count, fail_count); + + return fail_count > 0 ? 1 : 0; +} diff --git a/tests/test_signal.sh b/tests/test_signal.sh new file mode 100755 index 0000000..3df1e15 --- /dev/null +++ b/tests/test_signal.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# Signal-safety integration tests for atch attach. +# Uses a forkpty()-based C harness for exact PID targeting. +# +# Usage: sh tests/test_signal.sh +# Builds the test harness automatically if needed. + +ATCH="${1:-./atch}" +TESTS_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +TESTDIR=$(mktemp -d) +export HOME="$TESTDIR" +SESSION="sig-test-session" +NOISY_SESSION="sig-noisy-session" + +trap '"$ATCH" kill "$NOISY_SESSION" >/dev/null 2>&1 || true; "$ATCH" kill "$SESSION" >/dev/null 2>&1 || true; rm -rf "$TESTDIR"' EXIT + +# Build the harness (graceful skip if cc fails) +HARNESS="$TESTDIR/test_signal" +if ! cc -o "$HARNESS" "$TESTS_DIR/test_signal.c" -lutil 2>/dev/null; then + echo "1..0 # SKIP cannot build forkpty harness" + exit 0 +fi + +# Start a background session for the tests to attach to +"$ATCH" start "$SESSION" sleep 9999 || { + echo "1..0 # SKIP failed to start test session" + exit 0 +} + +"$ATCH" start "$NOISY_SESSION" sh -c 'yes X' || { + echo "1..0 # SKIP failed to start noisy test session" + exit 0 +} + +# Wait for socket +i=0 +while [ $i -lt 20 ]; do + [ -S "$HOME/.cache/atch/$SESSION" ] && break + sleep 0.05 + i=$((i + 1)) +done + +if [ ! -S "$HOME/.cache/atch/$SESSION" ] || + [ ! -S "$HOME/.cache/atch/$NOISY_SESSION" ]; then + echo "1..0 # SKIP session socket did not appear" + exit 0 +fi + +# Run the harness +"$HARNESS" "$ATCH" "$SESSION" "$NOISY_SESSION" From 92a953289e8d470186744042afa585a4d9c499c1 Mon Sep 17 00:00:00 2001 From: Rick Morgans Date: Sun, 15 Mar 2026 11:10:08 +1030 Subject: [PATCH 6/6] chore: ignore *.dSYM directories in .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 420cca7..6fd430d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /.* !.gitignore *.o +*.dSYM/ *~ *.1 *.1.md