Skip to content

c2c stop kills an unrelated process when the recorded pid has been recycled (killed Signal Desktop) #85

Description

@XertroV

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:

  1. 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).

  2. 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 leadershipTgid != 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions