Skip to content

V2: MCP discovery can starve chat reads in the client request queue #53049

Open

Description

@mlimarenko

Description

The app's request queue reserves slots for session reads when git/worktree requests are slow, but MCP and command catalogs are not classified as slow. Four catalog requests for different locations can occupy all four slots. A subsequent chat message request stays in the client queue even if the server could answer it immediately.

Command catalogs can wait for MCP startup/discovery. A slow or unavailable MCP server therefore delays unrelated chat reads until a catalog request completes or hits its header deadline.

OpenCode version

V2 2.0.22, based on d1f8f5b.

Steps to reproduce

  1. Start four /api/command requests for different locations through createRequestQueue, holding their responses open.
  2. Request /api/session/<id>/message through that queue.
  3. Observe that all four catalogs are dispatched and the message request is not.

The same queue failure occurs for /api/mcp and /api/mcp/resource. A regression test demonstrates dispatch order; a loopback HTTP replay verifies that chat can complete before the held catalogs are released after the proposed fix.

Expected: discovery requests use the existing slow-request quota, preserving capacity for session reads. This is a reproduced starvation mechanism, not a claim that it explains every observed timeout.

Operating System

Debian GNU/Linux forky/sid, Linux x86-64.

Client and reproduction environment

OpenCode Desktop 2.0.22 on the workstation where intermittent chat loading was reported. The scheduler failure itself was reproduced with Bun 1.4.2 in the app queue test and a loopback HTTP fixture without plugins; it is not a CLI session-execution defect. A full Desktop trace correlating an original timeout to this mechanism has not been captured.

Activity

  1. mlimarenko commented on Oct 4, 2026

    @mlimarenko
    ContributorAuthor

    Added the operating system, Desktop version and exact reproduction environment to the report.

    I checked #52852 and #48386. They concern repeated requests on one MCP connection and a toggle awaiting resource refresh, respectively. This issue concerns the shared client request queue: four pending catalog requests consume every slot, so a chat message read is not dispatched. Reducing discovery traffic or making the toggle asynchronous does not by itself reserve request capacity for chat reads; #53050 addresses that scheduler behavior.

  2. github-actions commented on Oct 4, 2026

    @github-actions
    Contributor

    Thanks for updating the issue with the system details and reproduction context!

  3. opencodejr commented on Oct 27, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I can confirm the queue behaviour you describe.

    Using the app's createRequestQueue with a fake fetch that holds catalog responses open, I started four requests to /api/command for different locations and then requested /api/session/<id>/message. The queue ended up with 4 requests in flight and the message request waiting; it never reached the server. /api/mcp and /api/mcp/resource behave the same way. This happens on v2.0.22 and still on the latest dev (c250c272c): slowRequestPaths there is still only /api/vcs and /api/worktree. The recent priority change for the first transcript page only changes the read's place in the waiting line; it doesn't free a slot. The script below reproduces it.

    In a quick check, counting /api/command as slow let the message request through. Related: #53458 is an open PR that references this issue but takes a different approach (more slots and priority for writes).

    Reproduction script (queue-starvation.ts)
    // Reproduces #53049: catalog requests (/api/command, /api/mcp, /api/mcp/resource) fill every slot
    // in the app's request queue, so a chat transcript read is never dispatched.
    // Usage: bun /workspace/repro/queue-starvation.ts [path/to/request-queue.ts]
    // Default module: /workspace/repo/packages/app/src/runtime/server/request-queue.ts
    const modulePath = process.argv[2] ?? "/workspace/repo/packages/app/src/runtime/server/request-queue.ts"
    const { createRequestQueue } = await import(modulePath)
    
    let failed = false
    for (const catalog of ["/api/command", "/api/mcp", "/api/mcp/resource"]) {
      const dispatched: string[] = []
      const held: Array<() => void> = []
      const fakeFetch = ((req: Request) => {
        const u = new URL(req.url)
        dispatched.push(u.pathname + u.search)
        // Catalog responses are held open (e.g. waiting on a slow MCP server); everything else answers at once.
        if (u.pathname === catalog) return new Promise<Response>((r) => held.push(() => r(new Response("[]"))))
        return Promise.resolve(new Response("[]"))
      }) as typeof fetch
      const q = createRequestQueue({ fetch: fakeFetch, log: () => {} })
    
      const catalogs = [1, 2, 3, 4].map((i) => q.fetch(`http://127.0.0.1:4096${catalog}?location=/proj${i}`))
      let chatDone = false
      const chat = q.fetch("http://127.0.0.1:4096/api/session/ses_1/message").then(() => (chatDone = true))
      await new Promise((r) => setTimeout(r, 200))
    
      const chatDispatched = dispatched.some((d) => d.startsWith("/api/session/ses_1/message"))
      console.log(
        `${catalog}: inflight=${q.inflight()} queued=${q.queued()} chatDispatched=${chatDispatched} chatDone=${chatDone}`,
      )
      if (!chatDispatched) failed = true
      held.forEach((r) => r())
      await Promise.all([...catalogs, chat])
    }
    console.log(failed ? "BUG: chat read starved behind catalog requests" : "OK: chat read dispatched while catalogs held")
    process.exit(failed ? 1 : 0)

    Run it: bun queue-starvation.ts

  4. mlimarenko commented on Oct 27, 2026

    @mlimarenko
    ContributorAuthor

    Thanks for independently reproducing this on current dev. The priority distinction is exactly the issue: priority orders requests that are still waiting, but cannot free a slot occupied by an in-flight catalog request.

    I checked all three paths against #53050 at e08a92fa69af846ca3466eca4ef41be6798bc229 using a controlled fetch that holds the four catalog responses. For each of /api/command, /api/mcp and /api/mcp/resource, only two catalog requests were dispatched, and the chat transcript read completed before any catalog response was released. All requests drained afterward. The resource path is covered by the same prefix classification, not a separate special case.

    #53050 retains the upstream write/navigation priorities and adds those catalogs to the existing slow-request quota. #53458 takes a broader approach, including raising the total budget from 4 to 12; its changed queue still classifies only VCS/worktree requests as slow. Increasing the budget can accommodate the four-request example, but sufficiently many held catalogs can fill the larger budget too. The catalog quota is the part that prevents that class of saturation.

    This verifies the queue scheduling mechanism, not every Desktop timeout or a server-side performance cause. #53050 is still open and unmerged.

    One small detail if reusing the reproduction against the fixed queue: the cleanup needs to release catalogs dispatched after the first batch is released as well. With a two-catalog cap, a one-time held.forEach(...) can otherwise leave the remaining queued catalogs pending.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions