Concurrent handler dispatch (follow-up to #16) #20
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Follow-up to #16. The Streamable HTTP rewrite delivered I/O concurrency (persistent SSE, sessions, multiplexed slow clients) but NOT handler concurrency. A synchronous tool handler (e.g.
shellrunningsleep 3) blocks the single-threaded Lua event loop for its full duration; concurrent fast POSTs wait behind it.Phase 7 of #16 measured this:
Why now is the right time
Without this, the otherwise-good transport rewrite has a sharp edge: clients may open multiple connections expecting parallel work but get strict serialisation. A user with a long
web_searchrunning can't evenpingthe server.Options (all substantial)
tools/callforks a child; main loop awaits SIGCHLD viasignal-fd/ pipe. Heavy on Linux, awkward on Windows. Best isolation.io.popen, therun()polling helper in server.lua, every curl shell-out) becomes a yield point. Invasive — every existing tool handler in server.lua needs auditing.server.lua:74-138with a state machine that yields between sentinel checks. Doesn't require touching individual handlers, butos.executeis still synchronous.Recommendation
Option 3 + option 2 hybrid for tools that already use
run(). Specifically:run()to return a coroutine-yieldable pollertools/calldispatch incoroutine.wrapfrom the event loop siderun()(pure-Lua handlers) continue to work synchronously — they're fast anywayEstimated 1-2 days. Should land BEFORE issues #9 (sampling) and #10 (roots) so server-initiated requests can flow during a long tool call without the event loop being frozen.
Scope
server.luarun()helper to a coroutine APIlmcp:run()Priority
High. Blocks the practical value of #9, #10. Also a UX paper cut for any user running
shell sleep N(test scenario but representative).Make this v1.1.0
Acked: v1.1.0. Recommendation from my own issue body still stands — option 3+2 hybrid (refactor server.lua run() helper to a coroutine-yieldable poller; tools that already use run() get concurrency for free; pure-Lua handlers remain synchronous). Will block #11 progress/cancellation usefulness on this one. Awaiting v1.1.0 milestone assignment from a repo-write account.
Implemented (commit
2ac502e, on master).How it works:
server.lua:sleep_mscheckscoroutine.running(); inside an lmcp coroutine it yields{ wake_at = gettime() + ms/1000 }. On the main thread it falls back to today's blocking sleep.server.lua:run()(the shell-out polling helper every non-pure-Lua tool goes through) now yields automatically viasleep_ms. Zero handler source-code changes._dispatch_postwrapshandle_requestincoroutine.create. Synchronous completion takes the inline-response path; yields park the coroutine inself._pending_handlersand the conn entersdispatching_async._scheduler_tick(new) services pending coroutines whose wake_at has passed;_finalise_dispatch(extracted helper) builds the deferred response with Accept-awareness preserved.select()timeout tightens to the earliest pending wake_at.Measurement (Phase 7):
Full regression suite passes: ping (#19), tools/list pagination+annotations (#12/#14), fetch structuredContent (#13), initialize Mcp-Session-Id (#16), stdio (#15), SSE-on-POST response shape.
Phase 5 review fixes folded in:
socket.gettime()is wall-clock; documented in_scheduler_tick. Acceptable on chrony-slewed fleet.run()uses gettime() deltas, not accumulatedintervalcounter — matches wall-clock under scheduler delays._finalise_dispatchre-readsconn.headers["accept"]so SSE-on-POST shape survives parking.Known limits (filed in memory
project_handler_coroutines):await(sampling/roots from inside a handler) still requires a future yield-on-pending helper.Unblocks the practical usefulness of #11. v1.1.0 release tag waits for #11 + #18 to also land.