Skip to content

test(mcp): report why the server did not answer, instead of hanging on it (#350) - #352

Open
Ercaner1988 wants to merge 1 commit into
trailhq:mainfrom
Ercaner1988:mcp-rpc-diagnosis
Open

test(mcp): report why the server did not answer, instead of hanging on it (#350)#352
Ercaner1988 wants to merge 1 commit into
trailhq:mainfrom
Ercaner1988:mcp-rpc-diagnosis

Conversation

@Ercaner1988

@Ercaner1988 Ercaner1988 commented Sep 10, 2026

Copy link
Copy Markdown

Fixes #350.

rpc() gave up after a fixed 15s, returned whatever had arrived, and dropped the child's stderr. Callers then dereferenced into undefined, so a slow server and a crashed one produced the same Cannot read properties of undefined (reading 'result').

Chasing that turned up something worse than the bad message, which is why this PR is a little larger than the issue implies.

A crashed server did not fail the run — it hung it

The last two lines of the helper were:

  child.kill();
  await once(child, 'exit').catch(() => {});

That works only while the child is still alive when kill() is called. If the server has already died on its own — a bad import, a missing native build, a throw during startup — exit has fired and will not fire again, so the await never settles. Isolated, without any of graft in the way:

$ node --input-type=module -e "
  import { spawn } from 'node:child_process';
  import { once } from 'node:events';
  const c = spawn(process.execPath, ['-e', 'process.exit(3)']);
  await new Promise(r => setTimeout(r, 300));   // let it die first
  c.kill();
  await once(c, 'exit').catch(() => {});
  console.log('never printed');
"
Warning: Detected unsettled top-level await

End to end, with the server replaced by something that writes to stderr and exits 3:

result
before still running when I killed it at 120s — not one of the file's five tests had reported anything
after the whole file finishes in 3s, the failing test reporting in 0.1s

After

Error: mcp server answered 0 of 3 in 0.1s — it quit first (exit code 3, signal null)
--- its stderr ---
native build missing
    at rpc (test/mcp-server.test.ts:69:11)

and, when the server is up but silent (deadline dropped to 100ms to force it):

Error: mcp server answered 0 of 3 in 0.1s — it was still running
It wrote nothing to stderr.

Those two are now different sentences, which is the whole point of the issue.

What changed, in one helper

  • Throws instead of returning short. How many of how many arrived, and how long it waited.
  • Keeps the child's stderr and puts it in that error. It was already piped; it was just never read. This is the line that turns "the tool list was empty" into "the native build is missing".
  • Waits on a close promise created at spawn time, so it settles whether the child crashed on its own or was killed at the end. This is the hang.
  • Stops waiting when the child quits. No point sitting out a deadline nothing can arrive before — and it is what makes the crash case 0.1s rather than a full timeout.
  • The deadline goes 15s → 60s, named. Only reachable now by a server that is up and silent, and the loop leaves the moment the replies are in, so a generous number costs nothing in the passing path. It is also the number that was actually flaking: ~2s of work against 15s sounds like ample headroom and is not, once the suite is running ~30 files in parallel.

close rather than exit throughout: it fires once stdio is drained, so a server that answers and then quits is not misread as having quit without answering.

Verified

Environment

Node v24.16.0, Windows 11 x64, on f9e6539 (0.18.0 plus the brain commits after it).

🤖 Generated with Claude Code

@trailhq-graft

trailhq-graft Bot commented Sep 10, 2026

Copy link
Copy Markdown

🌱 graft blast radius

Nothing outside this diff depends on it. 0 areas changed; no indexed dependents at depth 2.

graft blast · origin/main...HEAD · depth 2 · 1 changed file

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Sep 10, 2026
…n it

rpc() gave up after a fixed 15s, returned whatever had arrived, and dropped the
child's stderr. Callers dereferenced into `undefined`, so a slow server and a
crashed one both surfaced as `Cannot read properties of undefined (reading
'result')` — naming neither the timeout nor the request.

The crash case was worse than badly reported. `await once(child, 'exit')` after
`child.kill()` only settles while the child is still alive to be killed; a
server that died on its own has already fired `exit` and will not fire it
again, so the await hangs forever. Replacing the server with something that
writes to stderr and exits 3: the file was still running when I killed it at
120s, with none of its five tests having reported. It now fails in 0.1s with
the exit code and the server's own stderr in the message.

- Throw instead of returning short: how many of how many, and how long it took.
- Keep the child's stderr and put it in that error. It was piped already; it
  was just never read.
- Await a `close` promise created at spawn time, so it settles whether the
  child crashed or was killed. This is the hang.
- Stop waiting the moment the child quits — nothing can arrive after that.
- Deadline 15s to 60s, named. Only a live-but-silent server can reach it now,
  and the loop leaves as soon as the replies are in, so it costs nothing when
  things work. 15s was the number that flaked: ~2s of work has less headroom
  than it looks with ~30 test files running in parallel.

`close` rather than `exit` throughout: it fires once stdio is drained, so a
server that answers and then quits is not misread as having quit without
answering.

Fixes trailhq#350

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

test/mcp-server.test.ts: a timed-out RPC is returned as a partial result, so a slow server and a crashed one fail identically

1 participant