Summary
c2c stop <name> SIGTERMs (then SIGKILLs) the pid recorded in
~/.local/share/c2c/instances/<name>/outer.pid with no check that the pid is
still the process c2c recorded. Instance directories are durable and pids are
recycled, so a stale instance eventually points at a stranger's process — and
c2c stop kills it, reports stopped, and exits 0.
This is not hypothetical. It happened on my machine on 2026-08-09.
What happened
A kimi e2e-test instance from 2026-07-19 was left behind:
$ cat ~/.local/share/c2c/instances/kimi-e2e-c/meta.json
{ "client": "kimi", "pid": 821300, "start_ts": 1784451281.242453, ... }
21 days later pid 821300 had been recycled onto a thread of Signal Desktop
(tgid 9505). kill(821300, 0) succeeds — /proc/<tid> exists for threads — so
every liveness check c2c had read "running". ps -p 821300 shows nothing, since
ps lists thread-group leaders, which is why this is easy to miss by hand.
$ c2c stop kimi-e2e-c
Instance 'kimi-e2e-c': stopped # exit 0
Signal Desktop was gone. kill(2) on Linux delivers to the thread group
containing the target thread, so the SIGTERM aimed at "the instance" hit the
whole application.
Reproduction
Faithful and safe — a Python process with a second thread has the same shape:
python3 -c 'import threading,time; threading.Thread(target=lambda: time.sleep(300)).start(); time.sleep(300)' &
VPID=$!; sleep 1
TID=$(ls /proc/$VPID/task | grep -v "^$VPID\$" | head -1)
mkdir -p /tmp/inst/victim && echo "$TID" > /tmp/inst/victim/outer.pid
C2C_INSTANCES_DIR=/tmp/inst c2c stop victim
On 0.15.0: Instance 'victim': stopped, exit 0, and the victim is dead.
Root cause
Two independent defects compound:
-
No process-identity check. pid_alive answers "does a process with this
number exist", which is not "is this still ours". Nothing compared the live
process against meta.json's start_ts, and nothing rejected a pid that is
a non-leader thread (/proc/<pid>/status Tgid != pid).
-
Two stop implementations, and the guarded one was dead code.
C2c_start.cmd_stop is exported but wired to nothing;
C2c_managed_cmd.stop_cmd open-coded its own SIGTERM/SIGKILL loop and is
what actually runs. Any guard added to the former is invisible to users.
The same unguarded pattern existed at five other sites that read a pid off disk
and signal it, including mcp__c2c__stop_self (MCP-reachable) and
c2c restart, which signals the inner pid's whole process group
(kill(-pid)) — a recycled pid there takes out every process in whatever group
it landed in.
Fix
New leaf module C2c_pid_identity (no c2c deps, so every layer can reach it):
- Thread-group leadership —
Tgid != pid means the number names a thread
inside another process and is categorically not ours. Needs no recorded
state, so it protects instances written before start_ts existed. This is
the check that catches the incident.
- Start time —
/proc/<pid>/stat field 22 + /proc/stat btime gives a
wall clock to compare against meta.json's start_ts (equality within
tolerance), or against a pidfile's own mtime as a one-sided upper bound
for records that have no spawn timestamp: a process that started after the
file naming it was written cannot be the process it names.
pidfile_pid_is_ours ~pidfile ~pid is now the guard at every kill site that
reads a pid from disk. The two stop implementations are collapsed into one
(C2c_start.stop_instance), so the guard cannot be bypassed by adding a
surface again.
Failure modes are deliberately asymmetric: refusing to stop a live instance is
an annoyance, signalling a stranger is not, so every resolvable ambiguity
resolves toward "not ours". Absent evidence (Pid_unverifiable) still counts
as ours, preserving existing behaviour where no verification is possible.
User-visible change
c2c stop on a recycled pid now refuses instead of killing:
error: refusing to signal pid 1501937 for instance 'victim': pid 1501937 is a
thread of process 1501932 (python3), not a c2c instance — the pid was recycled.
The instance is not running; its recorded pid now belongs to another process.
Remove the stale instance with: c2c dev instances clean-stale
c2c dev instances also stops reporting such instances as alive.
Coverage
ocaml/test/test_c2c_pid_identity.ml — 13 tests using real procfs (this test
process as the ours case, a thread of it as the recycled case). Mutation-tested:
removing the leadership check fails 3 of them.
Summary
c2c stop <name>SIGTERMs (then SIGKILLs) the pid recorded in~/.local/share/c2c/instances/<name>/outer.pidwith no check that the pid isstill the process c2c recorded. Instance directories are durable and pids are
recycled, so a stale instance eventually points at a stranger's process — and
c2c stopkills it, reportsstopped, and exits 0.This is not hypothetical. It happened on my machine on 2026-08-09.
What happened
A kimi e2e-test instance from 2026-07-19 was left behind:
21 days later pid 821300 had been recycled onto a thread of Signal Desktop
(tgid 9505).
kill(821300, 0)succeeds —/proc/<tid>exists for threads — soevery liveness check c2c had read "running".
ps -p 821300shows nothing, sincepslists thread-group leaders, which is why this is easy to miss by hand.Signal Desktop was gone.
kill(2)on Linux delivers to the thread groupcontaining the target thread, so the SIGTERM aimed at "the instance" hit the
whole application.
Reproduction
Faithful and safe — a Python process with a second thread has the same shape:
On 0.15.0:
Instance 'victim': stopped, exit 0, and the victim is dead.Root cause
Two independent defects compound:
No process-identity check.
pid_aliveanswers "does a process with thisnumber exist", which is not "is this still ours". Nothing compared the live
process against
meta.json'sstart_ts, and nothing rejected a pid that isa non-leader thread (
/proc/<pid>/statusTgid!= pid).Two stop implementations, and the guarded one was dead code.
C2c_start.cmd_stopis exported but wired to nothing;C2c_managed_cmd.stop_cmdopen-coded its own SIGTERM/SIGKILL loop and iswhat actually runs. Any guard added to the former is invisible to users.
The same unguarded pattern existed at five other sites that read a pid off disk
and signal it, including
mcp__c2c__stop_self(MCP-reachable) andc2c restart, which signals the inner pid's whole process group(
kill(-pid)) — a recycled pid there takes out every process in whatever groupit landed in.
Fix
New leaf module
C2c_pid_identity(no c2c deps, so every layer can reach it):Tgid != pidmeans the number names a threadinside another process and is categorically not ours. Needs no recorded
state, so it protects instances written before
start_tsexisted. This isthe check that catches the incident.
/proc/<pid>/statfield 22 +/proc/statbtimegives awall clock to compare against
meta.json'sstart_ts(equality withintolerance), or against a pidfile's own mtime as a one-sided upper bound
for records that have no spawn timestamp: a process that started after the
file naming it was written cannot be the process it names.
pidfile_pid_is_ours ~pidfile ~pidis now the guard at every kill site thatreads a pid from disk. The two stop implementations are collapsed into one
(
C2c_start.stop_instance), so the guard cannot be bypassed by adding asurface again.
Failure modes are deliberately asymmetric: refusing to stop a live instance is
an annoyance, signalling a stranger is not, so every resolvable ambiguity
resolves toward "not ours". Absent evidence (
Pid_unverifiable) still countsas ours, preserving existing behaviour where no verification is possible.
User-visible change
c2c stopon a recycled pid now refuses instead of killing:c2c dev instancesalso stops reporting such instances as alive.Coverage
ocaml/test/test_c2c_pid_identity.ml— 13 tests using real procfs (this testprocess as the ours case, a thread of it as the recycled case). Mutation-tested:
removing the leadership check fails 3 of them.