The MCP Server I Thought I Killed Was Still Holding the Port: 84 Teardown Trials
Closing a session should stop the MCP servers it started. Across 84 trials of four teardown strategies, a process-group SIGTERM left zero orphans — until the child called setsid, at which point it left 100%. Includes a full descendant-sweep implementation.
The first session of the morning came up one MCP server short.
The log had a single line: address already in use. The process holding the port was the same server I had shut down the night before.
The session was closed. The host process was gone. The child was very much alive.
As an indie developer I noticed because it was the only server that mattered that morning. Scale that up and the leftovers just accumulate quietly.
I wanted to know exactly how wide the gap is between "I stopped it" and "it stopped."
Rebuilding the same tree 84 times, changing only how it dies
MCP servers usually start as child processes of the host. A shell wraps a launcher, the launcher wraps the real binary. If any layer in that chain swallows a signal, the leaf survives.
So I reproduced a realistic three-level tree in the smallest form I could. The leaf holds a TCP port and does nothing else. The port matters: it turns "is it alive?" from a judgment call into a bind() that either succeeds or doesn't.
# srv.py — the leaf: grab a port, then waitimport socket, sys, os, timeport, mode, tag = int(sys.argv[1]), sys.argv[2], sys.argv[3]if mode == "own_group": os.setsid() # create its own session and process groups = socket.socket()s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)s.bind(("127.0.0.1", port))s.listen(8)open(tag + ".srv.pid", "w").write(str(os.getpid()))while True: time.sleep(0.05)
# wrap.py — a launcher that spawns and waits, forwarding nothingimport subprocess, sys, osport, mode, tag = sys.argv[1], sys.argv[2], sys.argv[3]open(tag + ".wrap.pid", "w").write(str(os.getpid()))subprocess.Popen([sys.executable, "srv.py", port, mode, tag]).wait()
A launcher that doesn't forward signals isn't a contrived worst case. If you start MCP servers through a package runner or a shell script, that is the default behavior, not the exception.
The host starts the tree with start_new_session=True, waits for LISTEN, applies one teardown strategy, then polls every 10ms for up to 1.5 seconds — checking both leaf liveness and whether the port can be re-bound.
Twelve trials per condition, seven conditions, 84 trials total. Startup to LISTEN was a median of 41.5ms with no meaningful variation between conditions.
PID-targeted SIGTERM: 12 orphans out of 12
Start with the obvious approach. Send SIGTERM to the one PID the host is holding onto.
os.kill(top.pid, signal.SIGTERM)
Orphan rate: 100%. Port still held: 100%. Every single trial left the leaf running, and every single trial failed the next bind().
The mechanism is not subtle. SIGTERM goes to one process. It does not descend. The launcher exits cleanly, the leaf loses its parent and gets reparented to PID 1, and from the host's perspective the process it spawned is gone.
That's the part that bit me. The teardown returns success. Nothing appears in the logs. The failure stays invisible until tomorrow morning's address already in use.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Four teardown strategies across 84 trials: PID-targeted SIGTERM orphans 100% of the time, process-group SIGTERM orphans 0% — and setsid puts it right back at 100%
✦The TERM-grace-KILL pattern wastes the entire 500ms grace window: 0 of 12 processes exited during it, while a correctly delivered signal finishes in 10.4ms
✦A complete descendant sweep over /proc ppid links, including zombie detection and a PID-reuse guard, that stops the tree in 10.8ms
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Next, target the whole group. The tree was started with start_new_session=True, so everything should share one process group.
os.killpg(top.pid, signal.SIGTERM)
Orphan rate 0%. Port released in a median of 10.4ms, p90 10.5ms. Twelve clean teardowns out of twelve.
I nearly stopped there and wrote "use killpg" as the conclusion.
Then I ran the condition where the leaf calls os.setsid() itself — something daemonizing servers and signal-averse runtimes genuinely do.
Orphan rate went back to 100%.
The moment a process calls setsid(), it becomes the leader of a new session and a new process group. Its group ID no longer matches its parent's, so killpg simply doesn't reach it. The parent-child relationship is untouched; only signal delivery breaks.
Which means process-group targeting rests on an assumption you don't control: that the child stays in your group. MCP servers are somebody else's code. You don't get to make that promise on their behalf.
What the grace period was hiding
The common production pattern is two-stage: send SIGTERM, wait a grace period, escalate to SIGKILL if anything is left. I measured that too — SIGTERM to the top PID, 500ms grace, then a group-wide SIGKILL.
Against a normal child: 0% orphans. But the median time to port release was 516.9ms.
The breakdown is what stopped me. Zero of twelve processes exited during the grace window. All 500ms were spent every time, and the only thing that actually worked was the final SIGKILL.
A grace period is supposed to give a process time to shut down politely. Here the SIGTERM never reached the leaf, so waiting changed nothing. The grace window was doing one job: hiding the fact that the signal was misdelivered, for exactly 500ms.
When the signal does land, shutdown takes 10.4ms. We were paying a 50x penalty to conceal a delivery failure.
And against a setsid child, the two-stage pattern still orphaned 100% of the time. SIGKILL is group-targeted too. Escalating to a stronger signal doesn't help when the address is wrong.
Teardown strategy
Leaf behavior
Orphan rate
Port still held
Time to release (median / p90)
SIGTERM to PID
normal
100%
100%
never released
SIGTERM to process group
normal
0%
0%
10.4ms / 10.5ms
SIGTERM to process group
setsid
100%
100%
never released
TERM → 500ms grace → group KILL
normal
0%
0%
516.9ms / 517.5ms
TERM → 500ms grace → group KILL
setsid
100%
100%
never released
Descendant sweep
normal
0%
0%
10.8ms / 11.0ms
Descendant sweep
setsid
0%
0%
10.8ms / 11.0ms
Twelve trials per condition. "Port still held" is the share of trials where the same port could not be re-bound immediately after teardown returned.
Walking ppid links to build your own target list
If group IDs aren't trustworthy, use something that is. setsid() changes the session and the process group. It does not change the ppid.
Read ppid from every entry under /proc, rebuild the tree, and you can enumerate descendants no matter which group they wandered into.
"""Stop MCP servers started as child processes, without leaving anything behind."""import osimport signalimport timedef _read_stat(pid): """Return (ppid, starttime) from /proc/<pid>/stat, or None if unreadable.""" try: with open(f"/proc/{pid}/stat", "r") as f: raw = f.read() except (FileNotFoundError, ProcessLookupError, PermissionError): return None # comm can contain spaces and ')', so split on the last ') ' try: fields = raw.rsplit(") ", 1)[1].split() except IndexError: return None return int(fields[1]), int(fields[19]) # ppid, starttimedef _is_live(pid, starttime): """Liveness excluding zombies, with a PID-reuse guard.""" st = _read_stat(pid) if st is None or st[1] != starttime: return False # dead, or a different process reusing the PID try: with open(f"/proc/{pid}/cmdline", "rb") as f: return len(f.read()) > 0 # zombies keep /proc but empty cmdline except OSError: return Falsedef snapshot_tree(root_pid): """Collect descendants of root_pid as (pid, starttime), leaves first.""" children, meta = {}, {} for entry in os.listdir("/proc"): if not entry.isdigit(): continue pid = int(entry) st = _read_stat(pid) if st is None: continue children.setdefault(st[0], []).append(pid) meta[pid] = st[1] ordered, stack = [], [root_pid] while stack: pid = stack.pop() if pid in meta: ordered.append((pid, meta[pid])) stack.extend(children.get(pid, [])) return list(reversed(ordered)) # kill leaves before their parentsdef terminate_tree(root_pid, grace=0.5, poll=0.01): """SIGTERM every descendant, SIGKILL only what outlives the grace window. Returns: (targets, forced_kills) """ targets = snapshot_tree(root_pid) if not targets: return 0, 0 for pid, _ in targets: try: os.kill(pid, signal.SIGTERM) except (ProcessLookupError, PermissionError): pass deadline = time.monotonic() + grace while time.monotonic() < deadline: if not any(_is_live(pid, st) for pid, st in targets): return len(targets), 0 time.sleep(poll) killed = 0 for pid, st in targets: if _is_live(pid, st): try: os.kill(pid, signal.SIGKILL) killed += 1 except (ProcessLookupError, PermissionError): pass return len(targets), killed
Those are the last two rows of the table. Normal child or setsid child, the result is the same: 0% orphans, 10.8ms to release. Not one target ever reached the SIGKILL stage. When the address is right, the grace window costs almost nothing.
Killing leaves first matters. Remove a parent and every surviving child gets reparented to PID 1, which erases the link you were about to follow. Enumerate once, before any signal goes out, then treat that array as your complete target list.
The starttime comparison covers the window between enumeration and kill where a PID could be recycled. At tens of milliseconds per teardown, that window is small. It still felt wrong to leave a path open that could signal an unrelated process.
Zombies were corrupting my liveness check
The first time I measured the sweep, the median came out at 505.9ms.
The full grace window, consumed. But I was walking ppid links, so the targets had to be correct. I spent a while suspecting kill return values and permissions.
The bug was on the observation side, not the teardown side. My liveness check was os.path.exists(f"/proc/{pid}").
An exited process stays a zombie until its parent reaps it, and zombies keep their /proc entry. The launcher was exiting at the same moment, so nothing was left to reap the leaf. The directory sat there until the grace window expired.
The server had actually died in about 10ms. The port had been free the whole time. I was staring at the shell of a dead process.
Switching the check to "cmdline is non-empty" dropped the median to 10.8ms — zombies clear their cmdline, so only real processes count.
The lesson generalizes: teardown correctness depends on delivery and observation. Get delivery right but observation wrong, and a correct implementation looks slow. Get observation wrong in the other direction, and you report success on processes that are still running. That second failure is exactly what the first three conditions were doing.
Choosing between them
If every server in the tree is code you wrote, a process-group SIGTERM is enough. It finishes in 10.4ms and it's one line. You can guarantee nobody calls setsid() only inside your own repository.
The moment you start a third-party MCP server, the ppid-based sweep earns its keep. The extra cost is one enumeration pass, and teardown time barely moved.
If you already run a grace-then-escalate teardown, instrument it once: count how many processes actually exit during the grace window. If that number sits at zero, the grace period isn't a safety margin. It's a place for misdelivered signals to hide.
And whatever you build, give yourself one external signal — a port, a lock file, a socket. Process liveness can be fooled by zombies. "Does the next bind() succeed?" cannot. That single check is the reason the differences between strategies showed up as cleanly as they did.
One stuck server this morning, and the most stubborn thing I found was my own assumption about what "stopped" meant.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.