carlos / platform Public

gate DIAGNOSTIC (remove with #359): sigwho.py names the sender of the SIGTERM

The gate step runs make test under a Python wrapper that blocks SIGTERM, SIGINT and SIGHUP, waits for one with sigwaitinfo, prints the sender's pid, uid and command line (if visible in this pid namespace) and the process table, forwards the signal to make, waits, and dies by the same signal so the runner reports what it reported before. Thirteen runs have died by SIGTERM 35 s after the root shards start; nothing in the tree or the runner sends it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8d4740d00da56b31e4c9b3bc9b960f8d159a6703 parent b4f4cfb
2 files changed, +57 −1
  • .amadan/ci.d/10-gate +2 −1
  • .amadan/sigwho.py +55 −0

CI failed — run details

CI log
=== gate ===
sigwho: pid 4 ppid 2 pgid 0 sid 0; child pid 16
.amadan/test-gate.sh
gate: 2026-10-24T02:13:20Z on aarch64, 32 cpus, 62014 MB available, go1.26.8 linux/arm64
gate: +0s start launchctx
gate: +0s start vet
gate: +0s start gofmt
gate: +1s start rest
gate: +24s start internal_console@0
gate: +24s start internal_console@1
gate: +24s start internal_console@2
gate: +24s start internal_console@3
gate: +24s start internal_console@4
gate: +24s start internal_console@5
gate: +24s start internal_console@6
gate: +24s start internal_console@7
gate: +24s waiting for internal_console's shards before the next split package
gate: +187s start root@0
gate: +187s start root@1
gate: +187s start root@2
gate: +187s waiting for root's shards before the next split package

amadan: step gate failed: signal: terminated

amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate
index daf132329..b6963187c 100755
--- a/.amadan/ci.d/10-gate
+++ b/.amadan/ci.d/10-gate
@@ -51,6 +51,7 @@ set -eu
go build ./... &
build=$!
status=0
-make test || status=$?
+# DIAGNOSTIC (remove with #359): sigwho.py reports who sends the SIGTERM.
+python3 .amadan/sigwho.py make test || status=$?
wait "$build" || status=1
exit "$status"
diff --git a/.amadan/sigwho.py b/.amadan/sigwho.py
new file mode 100644
index 000000000..bc76b81e3
--- /dev/null
+++ b/.amadan/sigwho.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""Diagnostic wrapper (remove when #359 is answered): runs its arguments as a
+child and, when SIGTERM/SIGINT/SIGHUP arrives at THIS process, prints who sent
+it (si_pid, si_uid, the sender's cmdline if it is visible in this pid
+namespace) and the process table, forwards the signal to the child, waits,
+then dies by the same signal so the runner sees what it saw before."""
+import os, signal, subprocess, sys, time
+
+SIGS = {signal.SIGTERM, signal.SIGINT, signal.SIGHUP}
+
+def rd(p):
+ try:
+ with open(p, 'rb') as f:
+ return f.read().replace(b'\0', b' ').decode('utf-8', 'replace').strip()
+ except OSError as e:
+ return f'<{e.__class__.__name__}>'
+
+def main():
+ signal.pthread_sigmask(signal.SIG_BLOCK, SIGS)
+ child = subprocess.Popen(sys.argv[1:], preexec_fn=lambda: signal.pthread_sigmask(signal.SIG_UNBLOCK, SIGS))
+ print(f"sigwho: pid {os.getpid()} ppid {os.getppid()} pgid {os.getpgid(0)} sid {os.getsid(0)}; child pid {child.pid}", flush=True)
+ t0 = time.time()
+ while True:
+ info = signal.sigtimedwait(SIGS, 1.0)
+ if info is not None:
+ sig = info.si_signo
+ print(f"sigwho: +{time.time()-t0:.1f}s received signal {sig} from pid {info.si_pid} uid {info.si_uid} code {info.si_code}", flush=True)
+ print(f"sigwho: sender cmdline: {rd(f'/proc/{info.si_pid}/cmdline')} | comm: {rd(f'/proc/{info.si_pid}/comm')} | status ppid: {[l for l in rd(f'/proc/{info.si_pid}/status').splitlines() if l.startswith('PPid')]}", flush=True)
+ try:
+ ps = subprocess.run(['ps', '-eo', 'pid,ppid,pgid,sid,etimes,comm,args', '--sort=pid'], capture_output=True, text=True, timeout=5).stdout
+ lines = ps.splitlines()
+ print("sigwho: process table (" + str(len(lines)-1) + " processes):", flush=True)
+ for l in lines[:120]:
+ print(" " + l[:200], flush=True)
+ except Exception as e:
+ print(f"sigwho: ps failed: {e}", flush=True)
+ try:
+ child.send_signal(sig)
+ except ProcessLookupError:
+ pass
+ try:
+ child.wait(timeout=20)
+ except subprocess.TimeoutExpired:
+ child.kill()
+ signal.pthread_sigmask(signal.SIG_UNBLOCK, SIGS)
+ signal.signal(sig, signal.SIG_DFL)
+ os.kill(os.getpid(), sig)
+ time.sleep(1)
+ sys.exit(128 + sig)
+ rc = child.poll()
+ if rc is not None:
+ sys.exit(rc if rc >= 0 else 128 - rc)
+
+if __name__ == '__main__':
+ main()