fix: never let an umwelt child inherit the JSON-RPC stdin #14

Merged
troed merged 2 commits from fix/stdin-inheritance into main 2026-11-08 16:28:07 +01:00
Owner

Follow-up to #13 (merged as 0b3b77c). #13 fixed a real problem — a serialised front door — but it did not fix the hangs users were still seeing. This does.

Problem

umwelt's children inherited the MCP server's stdin.

podman() called subprocess.run(..., input=stdin) with stdin=None. That does not mean "no input" — it means inherit fd 0, and fd 0 of the MCP server is the JSON-RPC pipe from opencode. Six call sites then run podman exec -i, which keeps stdin attached and forwards it into the container for the whole call. So while a read_file, edit_file, read_image or read_chunk exec is alive, podman is reading the same pipe as the front door's reader. A request line that arrives in that window is swallowed by podman and never reaches the server: no response, ever. The client waits, the harness shows nothing, and the tool part stays running indefinitely.

It needs a concurrent sender to leave a request pending while a -i exec is alive — which is exactly why it always hurt more with subagents. It predates #13; #13's concurrency widened the window rather than creating the bug.

Evidence

  • /home/troed/dev/onmymind, session ses_e481de92…: an umwelt_edit_file on src/plugin.ts issued at 15:10:33 was still running at 15:18, with no client-side transport error. Its three siblings from the same message completed.
  • The opencode log contains Invalid MCP stdio message from umwelt … "id": null … Unrecognized key: "error" — one of the server's parse-error frames, which can only occur when a split line reaches the reader. A line split in two is what a reader stealing from the middle of the pipe produces.
  • podman exec -i consuming the caller's stdin is long-standing, documented behaviour (the classic while read line; do podman exec -i …; done that skips every other line).

Fix

  • podman() passes stdin=subprocess.DEVNULL whenever no payload is supplied — no child inherits fd 0. An explicit payload still goes in as a pipe.
  • -i dropped from the four exec sites that never feed stdin (edit_file, read_file, read_image, read_chunk); kept for write_file / write_chunk, which do.
  • Same treatment for the host-side git and keytool children, and for the run.py launch itself, which is the control plane every sandbox child inherits from.

Tests

  • New: test_podman_never_inherits_the_servers_stdin — pins stdin=DEVNULL for the no-payload path and input= for the payload path.
  • Four existing argv assertions inverted: reads and edits must not pass -i.
  • Suite: python3 -m pytest -m "not integration and not root and not mitmproxy" -q → 1312 passed, 44 deselected (8 warnings pre-existing).

After merge

Needs a rebuild + uv tool install --force and an opencode restart; a session that is already wedged keeps its stuck call until it restarts.

Follow-up to #13 (merged as `0b3b77c`). #13 fixed a real problem — a serialised front door — but it did **not** fix the hangs users were still seeing. This does. ## Problem umwelt's children inherited the MCP server's stdin. `podman()` called `subprocess.run(..., input=stdin)` with `stdin=None`. That does not mean "no input" — it means **inherit fd 0**, and fd 0 of the MCP server is the JSON-RPC pipe from opencode. Six call sites then run `podman exec -i`, which keeps stdin attached and forwards it into the container **for the whole call**. So while a `read_file`, `edit_file`, `read_image` or `read_chunk` exec is alive, podman is reading the same pipe as the front door's reader. A request line that arrives in that window is swallowed by podman and never reaches the server: no response, ever. The client waits, the harness shows nothing, and the tool part stays `running` indefinitely. It needs a concurrent sender to leave a request pending while a `-i` exec is alive — which is exactly why it always hurt **more with subagents**. It predates #13; #13's concurrency widened the window rather than creating the bug. ## Evidence - `/home/troed/dev/onmymind`, session `ses_e481de92…`: an `umwelt_edit_file` on `src/plugin.ts` issued at 15:10:33 was still `running` at 15:18, with no client-side transport error. Its three siblings from the same message completed. - The opencode log contains `Invalid MCP stdio message from umwelt … "id": null … Unrecognized key: "error"` — one of the server's parse-error frames, which can only occur when a *split* line reaches the reader. A line split in two is what a reader stealing from the middle of the pipe produces. - `podman exec -i` consuming the caller's stdin is long-standing, documented behaviour (the classic `while read line; do podman exec -i …; done` that skips every other line). ## Fix - `podman()` passes `stdin=subprocess.DEVNULL` whenever no payload is supplied — no child inherits fd 0. An explicit payload still goes in as a pipe. - `-i` dropped from the four exec sites that never feed stdin (`edit_file`, `read_file`, `read_image`, `read_chunk`); kept for `write_file` / `write_chunk`, which do. - Same treatment for the host-side `git` and `keytool` children, and for the `run.py` launch itself, which is the control plane every sandbox child inherits from. ## Tests - New: `test_podman_never_inherits_the_servers_stdin` — pins `stdin=DEVNULL` for the no-payload path and `input=` for the payload path. - Four existing argv assertions inverted: reads and edits must not pass `-i`. - Suite: `python3 -m pytest -m "not integration and not root and not mitmproxy" -q` → **1312 passed**, 44 deselected (8 warnings pre-existing). ## After merge Needs a rebuild + `uv tool install --force` and an opencode restart; a session that is already wedged keeps its stuck call until it restarts.
podman() passed input=None, so every child inherited fd 0 - the MCP
server's JSON-RPC pipe. Six call sites run `podman exec -i`, which reads
that inherited stdin and forwards it into the container for the whole
call, so a request line arriving meanwhile is swallowed: the client waits
forever and the tool part stays 'running'. That is the hang, and it needs
a concurrent sender (a subagent) to leave a request pending in the pipe.

Children now get stdin=DEVNULL unless a payload is supplied, and the
four exec -i sites that never pass a payload drop -i.
fix(mcp): launch the control plane without inheriting stdin
All checks were successful
ci / unit (pull_request) Successful in 1m21s
ci / publish (pull_request) Has been skipped
8f5b8b8edb
troed merged commit 89aacc0f99 into main 2026-11-08 16:28:07 +01:00
troed deleted branch fix/stdin-inheritance 2026-11-08 16:28:07 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
troed/umwelt!14
No description provided.