Skip to content

[Bug]: Claude skill discovery and favicon reads of project files can exhaust the libuv pool and wedge the host for hours (TCC-blocked open on a removable volume) #26417

Open

Description

@Wout-AxioSozo

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. On an unattended macOS host (screen locked), keep T3 Code Desktop (nightly) running with projects on an external USB volume (/Volumes/<ssd>, which TCC classifies as a Removable Volume). Connect to it from a second Mac over T3 Connect.
  2. Have any other app raise a TCC consent prompt on the host that nobody answers. In our case a freshly ad-hoc-signed dev app launched by automation did this. While that prompt is pending, sandboxd queues every later interactive TCC approval behind it. Once its 64-thread GCD pool (kern.wq_max_constrained_threads=64) is full, open() on existing files on the external volume blocks in the kernel. stat(), access() and ENOENT lookups still return immediately.
  3. Use the host normally from the client: send a Claude turn, or let server.refreshProviders / PR sync favicon enrichment run.

Deterministic repro without TCC (same idea as #15971): make <repo>/public/index.html or <repo>/.claude/settings.local.json a FIFO (mkfifo). Then open that project and start a Claude turn four times, or trigger favicon resolution.

Expected behavior

A project file that can't be opened shouldn't take down the server. Skill discovery and favicon lookup should skip or time out on that path, auth, secrets and tracing should keep working, and the client should stay connected.

Actual behavior

Four background reads of project files each take a libuv threadpool worker that never comes back. UV_THREADPOOL_SIZE is unset, so there are 4 workers in total. After that, every async fs, dns.lookup and crypto/zlib operation queues forever, while the main event loop stays idle and the process stays alive:

  • ServerSecretStore.get (plain readFile) never completes. /api/t3-connect/mint-credential returns 499 after about 8.6 s on every attempt (25 attempts over 70 min). /api/auth/websocket-ticket hangs in dpop.verifyRequestDpopProof on the replay-marker write, for 32,735 s. /.well-known/t3/environment returns 499.
  • The client shows EnvironmentRpcUnavailableError: <host> is not connected. for 9 hours, until the OS-level stall cleared. Nothing in T3 noticed. The desktop restarts the backend only when the process exits, and readiness is probed only at startup.
  • server.trace.ndjson stops being written, because the trace sink's fs.writeFile is queued too. After recovery the server wrote out the four long-running spans, so we know exactly which reads held the workers:
started duration span chain read on main @ 2a73827
23:21:51.402 33,093 s ProjectEnrichmentService.resolveFavicon > ProjectFaviconResolver.firstInOrder > findIconFromSource ProjectFaviconResolver.ts:214 readFileString(sourcePath) (index.html etc.; firstInOrder forks all 7 source reads at once)
23:22:31.799 32,089 s ws.rpc.server.refreshProviders > probeClaudeWorkspaceSnapshot > discoverClaudeSkills > discoverPluginSkillRoots > findPluginRepositoryRoot ClaudeSkills.ts:172 readFileString(path.join(root, ".git"))
23:27:15.002 32,518 s ws.rpc.server.refreshProviders > ... > discoverClaudeSkills > readSkillSettings ClaudeSkills.ts:259 readFileString(settingsPath) (<cwd>/.claude/settings.local.json)
23:27:18.615 32,787 s ProviderTurnStartService.start > ClaudeAdapterV2.startTurn > discoverClaudeSkills > findPluginRepositoryRoot ClaudeSkills.ts:172 again (4th worker; last span written before the wedge ends 23:27:18.017)

In each case the findRepositoryRoot child (exists, i.e. access()) on the same root finished in 0 ms, and the ENOENT sibling probes in the favicon resolver also finished in about 0 ms. Only real open()s of existing entries blocked.

findPluginRepositoryRoot is the worst case. In a normal (non-worktree) repo, .git is a directory, yet it is always opened with readFile. That fails with EISDIR, but only after an open() that needs authorization. A stat/isFile() first would have kept 2 of the 4 workers free here. Skill discovery runs on every Claude turn start and every refreshProviders, so this is on the hot path.

This is the same failure class as #15971 (AgentSessionScanner.readGitIdentity, TCC-blocked .git/config). The open PRs for it (#15977, #23690) fix only AgentSessionScanner. It is probably also the explanation for #24897: the triage there already suspected pool exhaustion, and the symptom (ServerSecretStore.get and mint-credential stuck, the tunnel healthy) is identical. #23877 shows that UV_THREADPOOL_SIZE=16 alone doesn't fix it.

Suggested direction (maintainers' call):

  • Don't read untrusted project paths through the shared pool unbounded. Add a per-volume (st_dev) cap, released from the native completion callback, not when the fiber is interrupted (with withPermits the permit leaks while the worker stays stuck in open()). Or do these reads synchronously in a dedicated worker thread or child process. Async fs in a worker_thread uses the same process-wide pool.
  • findPluginRepositoryRoot: stat <root>/.git and read it only if isFile() (gitdir file case).
  • Keep auth off the pool: cache server secrets in memory, and keep DPoP replay markers and cloud replay guards (consumeCloudReplayGuards) out of fs.
  • Desktop: add a continuous liveness probe that exercises the threadpool (and set a larger pool), and show "host unresponsive" instead of a generic "not connected".

Impact

Blocks work completely

Version or commit

0.1.4-nightly.20261027.3770 (host and client). The code paths above are unchanged on main @ 2a73827

Environment

Host: Mac mini, macOS 27.0.1 (26A434), arm64, T3 Code (Nightly) desktop (Electron 44.9.3), projects on an external USB APFS volume, reached over T3 Connect. Client: MacBook Pro, same build. Provider: Claude.

Logs or stack traces

# `sample <server pid> 10` on the host, ~9 h into the wedge (excerpt)
8469 Thread_159502997   DispatchQueue_1: com.apple.main-thread  (serial)
  ... node::SpinEventLoopInternal > uv_run > uv__io_poll > kevent     # 8449/8469: idle
8469 Thread_159503077: libuv-worker   ... > open > __open              # 8469/8469
8469 Thread_159503078: libuv-worker   ... > open > __open              # 8469/8469
8469 Thread_159503079: libuv-worker   ... > open > __open              # 8469/8469
8469 Thread_159503080: libuv-worker   ... > open > __open              # 8469/8469

# On the host during the wedge
$ curl -m 8 http://127.0.0.1:3773/          -> no response (timeout)
$ ps -o stat,%cpu -p <server pid>           -> Ss 0.0
$ ps eww -p <server pid> | grep UV_THREADPOOL_SIZE   -> (none)

# Recovery: the four spans above end at 08:33:13.329 / 20.519 / 24.528 / 45.236.
# Each is within 10 ms of the respawned sandboxd finishing one queued TCC approval.
# mint-credential 200 at 08:33:46.072, websocket-ticket 200 at 08:33:47.073; client reconnects 08:33:47.

Workaround

Answer the pending TCC dialog on the host (Screen Sharing), or keep projects off removable volumes. Restarting T3 alone doesn't help while the OS stall persists: the new server passes readiness, then wedges again on its first project read.

Activity

  1. Armand-Lluka commented on Nov 2, 2026

    @Armand-Lluka

    Another case of the same failure class, reached a different way: a headless macOS host, no removable volume, with projects in ~/Documents.

    Setup. 0.1.6-nightly.20261030.3917 ran as the launchd service (com.t3tools.t3code.service) on an unattended MacBook Pro (Apple silicon, macOS 27.0.1), reached from a second Mac. The projects lived under ~/Documents, and the launcher had no Files & Folders grant.

    What happened. An unrelated process prompted TCC for Documents access, and nobody was at the screen to answer. After that:

    • The server's git calls into the projects under ~/Documents blocked first.
    • All 4 libuv-worker threads then sat in uv_fs_open > __open; sample showed that across the whole run.
    • /.well-known/t3/environment failed with Interrupt (ServerSecretStore.get > readAgentActivityPublishingActive > ServerEnvironment.getDescriptor).
    • The client showed the host as reconnecting for hours, and nothing was logged on the host.

    Two details we haven't seen mentioned here:

    1. Restarting doesn't help, and startup can take minutes. The login-shell PATH probe (apps/server/src/os-jank.ts, spawnSync of zsh -ilc) blocks too if the user's shell startup files read anything under a protected folder. Ours sourced a file from ~/Documents. The server logged Listening only after that probe hit its timeout, about 4 minutes later, and then Failed to read PATH from login shell /bin/zsh. spawnSync /bin/zsh ETIMEDOUT.
    2. The grant is tied to a path that moves. TCC attributes the service's file access to the launcher, ~/.t3/runtime/versions/<version>/t3 (the path in ProgramArguments), not to the server binary. That path changes whenever t3 service install runs for a new version. So a Full Disk Access or Files & Folders grant given to the launcher silently stops applying after a repair or update, and the next protected read prompts again. A version-independent launcher path would let one grant survive updates.

    Workaround. We moved everything the service touches out of ~/Documents, ~/Desktop and ~/Downloads: project roots, shell startup files and symlinked dotfiles. We also added an external watchdog that polls /.well-known/t3/environment and alerts. After a reboot test, the server logged Listening 32 s after boot, and tccd showed no further folder requests from the launcher.

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