Repository navigation
SDK: unbounded concurrency in listSessions() exhausts file descriptors with a large session-state #5282
Description
Describe the bug
LocalSessionManager.listSessions() in @github/copilot/sdk fans out over every directory in ~/.copilot/session-state with an unbounded Promise.all, and per session reads workspace.yaml, reads events.jsonl for the first user message (summary), and reads events.jsonl again for detached-status detection. With N sessions the calling process briefly holds N to 2N open file handles.
Observed with 42,329 sessions (19 GB, created by copilot -p batch runs at roughly 470 per day; nothing prunes the store): a single listSessions() call from a VS Code extension host was captured via lsof with 19,985 events.jsonl files open at the same instant (20,043 of the process's 20,086 descriptors). Each call took 92 to 137 s. With 449 sessions it still takes 7 to 12 s.
Two consequences:
- Per process: on macOS
uv_spawnreturnsEBADFonce the child's stdio pipe descriptors are numbered above 10239, whatever the process limit is (uv_spawn returns EBADF on macOS when stdio pipe descriptors exceed 10239 libuv/libuv#5204, attributed to Apple libc). WhilelistSessions()holds more than ~10k handles the host cannot spawn or fork at all. In VS Code this killed the ESLint language server (Eslint client couldn't create connection to server after installing new VS Code version microsoft/vscode-eslint#2136), ripgrep and Copilot's own workspace indexer withspawn EBADFin the window that was listing. - System wide: five VS Code windows calling it in parallel at startup drove
kern.num_filesto 122,477 (limit 122,880) and unrelated processes gotENFILE;git rev-parsetook 1.5 to 3.4 s instead of ~50 ms.
De-minified shape of the code (sdk/index.js):
async listSessions(e){
let n=await _t.directoryFilesWithMetadata(this.settings), r=e?.metadataLimit, s=e?.includeDetached??!0,
o = s ? n : r===void 0
? (await Promise.all(n.map(async l=>({file:l,isDetached:await this.isDetachedSession(l.file,l.mtime.getTime())})))).filter(...)
: await this.getVisibleSessionEntriesWithBoundedDetachedDetection(n,r),
a=(await Promise.all(o.map(async(l,c)=>{ ...
let p = s ? await this.isDetachedSession(u, ...) : !1; // opens events.jsonl (readDetachedStatusFromSessionStart)
let m = await GI(u,this.settings); // reads workspace.yaml
if(!f){ let b=await qI(y); f=b.summary; h=b.isEmpty } // opens events.jsonl again (sessionExtractFirstUserMessage)Notes: the bounded helper getVisibleSessionEntriesWithBoundedDetachedDetection is only used when metadataLimit is set and includeDetached is false, so the default path has no bound; the detached cache only helps on later calls in the same process; nothing is cached across processes, so every client pays the full cost.
Affected version
GitHub Copilot CLI 1.0.105.
Steps to reproduce the behavior
cd ~/.copilot/session-state
for i in $(seq 1 20000); do d=$(uuidgen | tr A-Z a-z); mkdir "$d"
printf 'id: %s\ncwd: /tmp\ncreated_at: 2026-10-01T00:00:00Z\nupdated_at: 2026-10-01T00:00:00Z\n' "$d" > "$d/workspace.yaml"
printf '{"type":"session.start","data":{"sessionId":"%s"}}\n{"type":"user.message","data":{"content":"hello"}}\n' "$d" > "$d/events.jsonl"
done
# same construction the VS Code extension uses
node -e 'import("@github/copilot/sdk").then(async m=>{const sm=new m.internal.LocalSessionManager({createFeatureFlagService:m.createLocalFeatureFlagServiceCreator()(),telemetryService:new m.internal.NoopTelemetryService,autoModeManager:new m.AutoModeSessionManager},{}); console.time("list"); await sm.listSessions(); console.timeEnd("list")})' &
while kill -0 $! 2>/dev/null; do lsof -p $! 2>/dev/null | grep -c events.jsonl; sleep 0.5; doneOn macOS the open count climbs toward N; on a default ulimit -n 256 shell it fails with EMFILE. Adding a child_process.spawn inside the script while the listing runs yields spawn EBADF past ~10k open files.
Expected behavior
- Concurrency in
listSessionsis bounded (ap-limitstyle pool of 16 to 64), including the detached-detection pass, so the caller never holds more than a few dozen handles. events.jsonlis read once per session; detached status and the first user message come from the same prefix read.metadataLimitis effective on the defaultincludeDetached: truepath, with entries sorted by mtime first so the limit covers the most recent sessions.- The derived summary is persisted (in
workspace.yamlor in the existingsession-store.db) so later listings do not openevents.jsonlat all. - A prune or retention option exists for
~/.copilot/session-state(copilot sessionscurrently has onlyimport).
Additional context
- macOS (Darwin 27.2.0, arm64), zsh.
kern.maxfiles122,880,kern.maxfilesperproc61,440. - VS Code side: Copilot Chat re-walks the entire CLI session store on every refresh — extension host stalls for minutes with a large store microsoft/vscode#327021 (slow repeated re-walks) and a separate new issue for the descriptor exhaustion in the extension.
Activity