Skip to content

MCP stdio child teardown is sequenced after full actor-system shutdown and can be starved of budget; force-kill paths bypass it entirely #1667

Description

@Aaronontheweb

Problem

Stdio MCP child processes (e.g. a configured Playwright MCP server: npm exec @playwright/mcpsh -cnode, potentially a browser under that) are only terminated by McpClientManager.StopAsync/Dispose (src/Netclaw.Daemon/Mcp/McpClientManager.cs:73-90, 592-602), which calls the MCP SDK's disposal — a graceful 10s wait then Process.Kill(entireProcessTree: true) with a 10s wait, sequentially per configured server.

Two sequencing defects mean this code can run late or never:

  1. Late: McpClientManager is registered as an IHostedService before AddAkka (src/Netclaw.Daemon/Program.cs ~858-861 vs ~1058), so LIFO stop ordering runs Akka's hosted service first — and AkkaHostedService.StopAsync awaits CoordinatedShutdown.Run() to completion, ignoring the host's shutdown CancellationToken. When the session drain consumes its full 200s phase timeout (see Daemon shutdown: session drain blocks on turns awaiting interactive tool approval #1664), Akka shutdown eats nearly all of the 230s external budget (HostOptions.ShutdownTimeout / systemd TimeoutStopSec), leaving MCP teardown (up to ~20s per server, sequential) little or no time before the cgroup-wide SIGKILL.
  2. Never: the netclaw daemon stop force-kill path (see netclaw daemon stop: force-kill budget equals the daemon's drain-phase timeout, guaranteeing SIGKILL whenever the drain times out #1665) SIGKILLs only the main daemon PID. The SDK's Kill(entireProcessTree: true) never runs. Under systemd this is masked because the unit's cgroup sweep reaps the children; in any non-systemd deployment (container entrypoint, manual netclawd, dev shells), a force-killed daemon orphans the whole MCP child tree — including headless browser processes.

Observed on a production stop of 0.25.0-alpha.onnx.6: idle Playwright MCP children were still in the service cgroup 90+ seconds into the drain, and only died via systemd's sweep after the main PID was force-killed. Code is unchanged on dev/onnx.7 (the recent #1636 "process-bound stdio MCP" change is orthogonal — it fixed ownership scope, one process per configured server; its design doc explicitly preserves shutdown behavior).

Suggested direction

Trigger MCP child teardown from IHostApplicationLifetime.ApplicationStopping (fires before any IHostedService.StopAsync) so it runs in parallel with the session drain instead of after the full Akka shutdown. This is safe post-#1636 because MCP server state is documented as daemon-scoped, not session-isolated: killing the child early just fails any in-flight MCP call immediately — which is also exactly what unblocks a session wedged on an unresponsive MCP process, instead of that session holding the drain (compounding #1664).

Since this changes documented daemon-shutdown behavior for a spec'd capability (openspec/specs/netclaw-mcp/spec.md), it should go through the OpenSpec workflow rather than a drive-by patch.

Related: #1664 (drain blocks on approval-parked turns), #1665 (CLI kill-budget race).

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