Skip to content

bridge: shut down + release the port when the parent dies; enforce one bridge per agent - #20

Closed
terafin wants to merge 2 commits into
vbcherepanov:mainfrom
terafin:fix/bridge-dies-with-parent
Closed

bridge: shut down + release the port when the parent dies; enforce one bridge per agent#20
terafin wants to merge 2 commits into
vbcherepanov:mainfrom
terafin:fix/bridge-dies-with-parent

Conversation

@terafin

@terafin terafin commented Jul 17, 2026

Copy link
Copy Markdown

Problem

a2abridge bridge is spawned by an MCP host (Claude Code, Codex, etc.) as a stdio child that also binds an HTTP port for inbound A2A. Two ways a stale/duplicate bridge ends up holding the port so the next bridge can't bind (listen tcp :<port>: bind: address already in use) and the agent's outbound send path goes silently dead:

  1. Host exits → the bridge reparents to init (ppid 1) and keeps running, squatting the port.
  2. Mid-session respawn (e.g. an MCP reconnect) → a second bridge lingers alongside the first.

Observed across a multi-agent fleet: a stuck bridge held a port for hours, dropping inbound messages with no error surfaced; and a reconnect left a duplicate.

Fix — one bridge per agent, defence-in-depth (all in RunBridge)

  1. Linux PR_SET_PDEATHSIG(SIGTERM) (pdeathsig_linux.go): kernel signals the bridge the instant its parent dies, routed into the existing signal.NotifyContext(SIGTERM) graceful shutdown → listener closes, port frees. Set on a dedicated locked OS thread that blocks forever (PR_SET_PDEATHSIG is a per-thread attribute cleared if the setting thread exits, and the Go runtime migrates/retires threads), plus a getppid()==1 born-orphan check for the fork/exec race. No-op stub on non-Linux (pdeathsig_other.go).
  2. Shut down when ServeStdio returns: stdin closing means the parent is gone, so shut down even on a clean EOF (err == nil), not only on error.
  3. Bind is the singleton lock: retry briefly on EADDRINUSE (covers the port-release race on a fast restart), then if it's still held, defer to the existing bridge and exit cleanly (0) — exactly one bridge serves an agent even if a duplicate is spawned. Non-EADDRINUSE bind errors still fail hard.

(1)+(2) are redundant on purpose (kernel signal vs. stdin EOF). (3) closes both the release race and the duplicate-spawn case.

Verified

  • go build ./..., go vet ./..., go test ./internal/cli/... pass; cross-compiles clean for darwin/arm64 + windows/amd64 (platform split holds).
  • Die-with-parent: spawned a bridge as a child (stdin held open to isolate pdeathsig), killed the parent → bridge shut down gracefully and released the port; no ppid=1 orphan.
  • Singleton: started bridge A holding a port, started bridge B on the same port → B retried, logged port already held by another bridge, deferring, and exited 0, leaving A untouched.

🤖 Generated with Claude Code

…e bridge per agent

An `a2abridge bridge` is spawned by an MCP host (Claude Code, etc.) as a stdio child that also binds an HTTP port. Two failure modes left a stale/dup bridge holding the port so the next bridge couldn't bind ("address already in use") and the agent's outbound A2A went silently dead: (1) when the host exits, the bridge reparented to init and kept running; (2) a mid-session respawn (e.g. an MCP reconnect) could leave a second bridge lingering.

Fixes, all in RunBridge:

- Linux PR_SET_PDEATHSIG(SIGTERM): the kernel signals the bridge the moment its parent dies, routed into the existing SIGTERM graceful shutdown. Set on a dedicated locked OS thread that blocks forever (PR_SET_PDEATHSIG is a per-thread attribute cleared if the setting thread exits, and Go migrates/retires threads), plus a getppid()==1 born-orphan check. No-op stub on non-Linux (pdeathsig_linux.go / pdeathsig_other.go).
- Shut down whenever ServeStdio returns (stdin closed = parent gone), including a clean EOF, not only on error.
- Bind is the singleton lock: retry briefly on EADDRINUSE (covers the port-release race on a fast restart), then if it is still held, defer to the existing bridge and exit cleanly (0) — so exactly one bridge serves an agent even if a duplicate is spawned. Non-EADDRINUSE bind errors still fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@terafin
terafin force-pushed the fix/bridge-dies-with-parent branch from d2de19b to 028b4ab Compare July 17, 2026 07:49
@terafin terafin changed the title bridge: shut down and release the port when the parent process dies bridge: shut down + release the port when the parent dies; enforce one bridge per agent Jul 17, 2026
…agent

Behavior-level integration tests that build and run the real binary, so they
exercise the shipping code as-is (ZERO production change — bridge.go and the
pdeathsig files are byte-identical to the v3.0.3+1 blob) and port unchanged to
both the fleet blob and the upstream a2abridge#20 branch. Tag-gated
(//go:build integration) so they stay out of the default `go test ./...` / CI.

- TestBridgeSecondInstanceDefersToIncumbent: a duplicate bridge on the same
  bind address retries then exits 0, WITHOUT killing the incumbent or running
  portless (exactly one bridge per agent).
- TestBridgeShutsDownOnStdinEOF: closing stdin -> ServeStdio returns -> shutdown
  + port release, even on a clean EOF.
- TestBridgeDiesOnParentDeath (Linux): killing the parent -> PR_SET_PDEATHSIG ->
  shutdown + port release, with the bridge's stdin held open so the death is
  isolated to pdeathsig (not the stdin-EOF path).

Bot-author: sindri
(cherry picked from commit c68674b)
@terafin terafin closed this by deleting the head repository Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant