Skip to content

Cursor sessions run on the server's main thread, so a wide Cursor fan-out stalls every other request #31022

Open

Description

@rxsto

Note

Claude Opus 5.5 in Claude Code, writing on behalf of @rxsto. The measurements are from the reporter's server on 2026-11-04.

Before submitting

Area

packages/provider-cursor, apps/server

What happens

The Cursor provider runs every Cursor agent inside the T3 server process. loadCursorSdk() loads @cursor/sdk into the server with createRequire. CursorAgentSdkRunner.open then calls Agent.create or Agent.resume in the same process. Claude, Codex, and Grok each run as a separate process, and the server only relays their events.

So each Cursor session's JavaScript, its protobuf transport, and the rg and cursorsandbox processes it starts all run on the server's main thread. That thread also serves HTTP, WebSocket, and T3 Connect traffic, and it routes SQLite reads to the reader workers. With many Cursor sessions open at once, the main thread has almost no idle time left.

Steps to reproduce

  1. On Linux, run t3 serve with the Cursor provider signed in.
  2. Start 24 Cursor children at once with delegate_task, using provider cursor and model grok-4.7 with default options. Give each child a read-only task: list the files, run git log and git status, read up to 6 files, and reply with an 800-word summary.
  3. While the children run, record a CPU profile of the server's main thread. I sent SIGUSR1, then ran Profiler.start and Profiler.stop over CDP for 60 seconds.

If you reproduce this with the inspector, don't evaluate code in the server. I scheduled import('node:inspector').then((m) => m.close()) through Runtime.evaluate. The timer threw ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING, nothing caught it, and the server exited with code 1.

Expected behavior

A Cursor session costs the server's main thread about what a Claude or Codex session costs, which is relaying its events. A wide Cursor fan-out does not delay turn starts, thread reads, or auth checks for the other threads on the server.

Actual behavior

The profile covers 61.3 s and 37,377 samples, taken 5 minutes after a server restart:

Where the main thread spent its time Share
JavaScript in @cursor/sdk, @bufbuild/protobuf, and @connectrpc/connect 21.6%
spawn() called from Cursor SDK code 6.1%
T3 server JavaScript, mostly Effect fiber scheduling and schema decoding 54.8%
spawn() called from T3 server code 10.7%
Garbage collection 3.4%
Idle 3.4%

I sampled main-thread CPU from /proc/<pid>/task/<pid>/stat every 5 seconds. It stayed between 58% and 96% while the 24 sessions ran. On the same process, with 2 non-Cursor threads running, it averaged 9% over 60 seconds.

The same thing happened earlier without a test. An orchestrator thread started 27 Cursor children between 14:50 and 14:55 UTC. The server traces for the following hour show:

  • ProviderTurnStartService.start took a median of 46.6 s and up to 186 s.
  • CursorAgentSdkSession.send inside CursorAdapterV2.startTurn took 1,033 s across 27 turn starts, about 38 s each.
  • boot-service.log shows AgentSkillsCursorRulesService loads of up to 32 s and computeGlobalCache warnings of up to 14 s.
  • Lock waits made up 11,409 s of the 13,261 s that reader-connection queries spent in SQLite. ProjectionStore.isNodeStored, a primary-key read, took a median of 146 ms in the server. The same read took 0.66 ms from a separate process on the same database. Each of the 4 reader worker threads had used about 3.5 minutes of CPU in 8.6 hours of uptime, so the reads were waiting on the main thread, not on SQLite.
  • Main-thread CPU was 63% to 81% in five 2-second samples during that hour.

Possible fix

Run @cursor/sdk off the main thread, in a worker thread or a child process that owns the SDK. The adapter would talk to it over a message channel, the way the other providers talk to their CLIs. A child process would also move the SDK's own rg and cursorsandbox spawns out of the server's large address space, which avoids the fork cost in #21289.

Environment

  • T3 Code 0.1.7-nightly.20261104.4151 (322f654), t3 serve as a systemd user service, with clients over T3 Connect
  • Bundled Node v26.12.1 with libuv 1.52.1, and @cursor/sdk 1.2.0
  • Linux 7.0.0-42-generic, 8 vCPU AMD EPYC 9645, 15 GiB RAM
  • statev2.sqlite is 6.2 GB with 3 free pages
  • main at 9e85538 has the same code in sdk.ts and CursorAgentSdk.ts

I can attach both .cpuprofile files.

Activity

  1. henryzhang03 commented on Nov 7, 2026

    @henryzhang03

    Note

    Claude Opus 5.5 in Cursor, writing on behalf of @henryzhang03. The measurements are from the reporter's Mac on 2026-11-06.

    A second way to hit this: one long Cursor thread, with no fan-out running. Each message on that thread spends 15 to 21 s inside agent.send() on the main thread, and the whole server stops responding for that time.

    Environment: T3 Code (Beta) 0.1.7 desktop app with its local server, macOS 27.2 on Apple silicon, 96 GB RAM, Cursor provider.

    The thread: 12 runs, about 1,000 turn items, and 345 provider-native subagents left from earlier fan-outs. Their child threads hold about 19,800 more turn items.

    CursorAgentSdkSession.send for messages sent on that thread (server.trace.ndjson, UTC):

    Start Duration memory.heap_growth_bytes
    23:27:07 15.5 s 666 MB
    23:28:08 15.4 s 271 MB
    23:31:05 16.4 s 1,209 MB
    23:40:10 16.7 s 709 MB
    23:40:31 17.2 s 263 MB
    23:43:33 18.4 s not recorded
    23:47:58 21.5 s 752 MB
    23:48:25 20.1 s 367 MB
    • One other send on the same thread took 0.6 s. The other 12 sends in that window, for another thread or for agent and subagent turns, took 0.3 to 1.8 s.
    • None of these spans has a child span longer than 50 ms, so the time is spent inside agent.send().
    • event-loop-stalls.ndjson matches the sends. delayMaxMs rose from about 2.5 s earlier in the day to 20.7 s. The 20.7 s stall logged at 23:48:20 ends with the 21.5 s send that started at 23:47:58, and the 18.0 s stall logged at 23:43:52 ends with the 18.4 s send at 23:43:33. RSS in those records reached 5.7 GB.
    • While a send runs, the desktop window and every other thread stop responding. Opening this thread (ws.rpc.orchestration.subscribeThread) took 25.5 s.

    Running the SDK off the main thread, as proposed above, would stop the server-wide freeze. The cost of each send also grows with this thread's history, so turn starts on a thread like this would stay slow unless that work is bounded too.

    Earlier the same day, with 8 Cursor delegate_task children running, no server span completed for 4 minutes (17:22 to 17:26 UTC). HTTP requests to the server timed out after 15 s, and the desktop showed "T3 Code on did not answer". A native sample showed the main thread busy in JavaScript throughout. We did not take a JavaScript profile, so we can't say how much of that was SDK code, but it fits the fan-out case in this issue.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions