fix(mcp): stop one command from hanging the MCP server permanently - #317
Open
Lazare-42 wants to merge 1 commit into
Open
fix(mcp): stop one command from hanging the MCP server permanently#317Lazare-42 wants to merge 1 commit into
Lazare-42 wants to merge 1 commit into
Conversation
`batch` with neither --commands nor --input (and `--input -`) reads stdin.
Under MCP that is not a payload channel — it is the JSON-RPC transport, a
pipe the client holds open for the whole session, so ReadToEnd waits on an
EOF that never arrives. Because McpServer runs each command inline on its
single reader loop, that one call hangs the ENTIRE server: every later
request queues behind it unanswered until the process is restarted.
Seen in production behind an mcp-proxy stdio->HTTP bridge: one such call
took the server down for two days, and because nothing crashed and the port
stayed open, no supervisor noticed and nothing was logged. An agent asking
for `batch doc.docx` and supplying the array as a follow-up is enough.
- CommandBuilder.ReadBatchStdIn refuses stdin when McpServer.InMcpMode,
naming the two flags that do work. It keys off the mode, not the reader,
because StdIn bypasses Console.In by design.
- McpServer.RunCliRaw points Console.In at TextReader.Null as a second
line of defence for anything reading stdin the ordinary way.
- The batch stdin-warning Peek is skipped under MCP: StdIn is a shared
buffered reader over the transport, so a successful Peek consumes a
protocol byte into a buffer the reader loop never inspects.
- RunCliRaw runs each command on a dedicated background thread with a
180s cap (OFFICECLI_MCP_COMMAND_TIMEOUT_SECONDS, 0 restores the old
unbounded behaviour), so a future hang of any origin costs one request
rather than the server.
The timeout needs output routed per thread. Console.Out resolves at call
time, so swapping a fresh StringWriter in per invocation lets an abandoned
command follow the swap and empty its output into the next command's
response — verified before the routing was added. ThreadRoutedWriter binds
each command's buffers to its own thread, so an abandoned one writes only
to its own garbage. The redirects are never restored: the protocol stream
is RunAsync's private StreamWriter, so Console.Out is never the transport,
and leaving it redirected keeps a late write from landing mid-stream.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Under MCP,
officecli mcpcan be taken down permanently by a single tool call, and nothing about the failure is visible: no crash, no log line, the port stays open and the process stays alive.The bug
batchwith neither--commandsnor--input(and--input -) falls through to reading stdin. Under MCP, stdin is not a payload channel — it is the JSON-RPC transport, a pipe the client holds open for the whole session, soReadToEndwaits for an EOF that never arrives.McpServer.RunAsyncinvokes each command inline on its single reader loop, so that one blocked read hangs the entire server. Every subsequent request queues behind it, unanswered, until the process is restarted.An agent asking for
batch doc.docxand intending to supply the array as a follow-up is enough to trigger it.I hit this in production behind an
mcp-proxystdio→HTTP bridge. The server went dark for two days on one such call. Because nothing exited,Restart=alwaysnever fired and no supervisor noticed — the only symptom was requests timing out client-side.Reproduces on
main(v1.0.144). Hold stdin open the way a client does, then:initializeis answered. Neither thetools/callnor thepingever is.The fix
CommandBuilder.ReadBatchStdInrefuses stdin whenMcpServer.InMcpMode, naming the two flags that do work. It keys off the mode, not the reader —StdInbypassesConsole.Inby design, so a redirect-based guard alone would not cover it.McpServer.RunCliRawpointsConsole.InatTextReader.Nullas a second line of defence for anything reading stdin the ordinary way.Peekis skipped under MCP.StdInis a shared buffered reader over the transport, so a successfulPeekconsumes a protocol byte into a buffer the reader loop never inspects. This one is a latent stream-corruption bug independent of the hang.OFFICECLI_MCP_COMMAND_TIMEOUT_SECONDS,0restores unbounded), so a future hang of any origin costs one request instead of the server.One subtlety worth flagging in review
The timeout requires output to be routed per thread.
Console.Outresolves at call time, so the obvious approach — swap a freshStringWriterin around each invocation — lets an abandoned command follow the swap and empty its output into the next command's response. I hit exactly that before adding the routing: a timed-outbatchdumped its[1] Added paragraph…lines into an unrelatedgetreply.ThreadRoutedWriterbinds each command's buffers to its own thread, so an abandoned command writes only to its own garbage. The redirects are deliberately never restored: the protocol stream isRunAsync's privateStreamWriter, soConsole.Outis never the transport, and leaving it redirected stops a late write from landing mid-stream.Verification
Built clean against
main(0 errors). Against the patched build, all six requests above are answered: both stdin forms return an immediate actionable error, and--commands,add,get,tools/listandpingare unaffected. Separately verified with a 1s cap that a timed-out command returns its error, the server answers the next request normally, and no output bleeds across commands.Not included: the
mmdcsequential-ReadToEnddeadlock inMermaidImageRenderer, already fixed onmain.