ANTIGRAVITY LABJP
Articles/Integrations
Integrations/2026-08-08Advanced

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.

mcp15processsigtermsetsidteardownmeasurement7

Premium Article

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 wait
import socket, sys, os, time
 
port, mode, tag = int(sys.argv[1]), sys.argv[2], sys.argv[3]
if mode == "own_group":
    os.setsid()                     # create its own session and process group
s = 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 nothing
import subprocess, sys, os
 
port, 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.

or
Unlock all articles with Membership →
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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Integrations2026-08-03
I Measured Before Writing a Number: MCP Connect and Tool Calls Differed by 486x
Antigravity 2.4.3 lets you set a timeout per MCP server. To find a defensible number I built a stdio server, measured each boundary separately, and found why a single value cannot cover both.
Integrations2026-05-06
Antigravity × Stripe Custom MCP Server: Complete Implementation Guide — Autonomous AI-Driven Billing
Build a custom MCP server that wraps the Stripe API, letting Antigravity's AI agents autonomously handle subscriptions, Webhooks, and multi-tenant billing. A complete TypeScript implementation guide for production-grade SaaS billing.
Integrations2026-05-04
Connecting Antigravity to Gmail: Setting Up Google Workspace MCP and Practical Workflow Patterns
Learn how to connect Gmail, Google Calendar, and Sheets to Antigravity via MCP so you can manage your inbox, schedule, and tasks without ever leaving your editor. Includes step-by-step setup and real workflow patterns.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →