Skip to content

SDK: unbounded concurrency in listSessions() exhausts file descriptors with a large session-state #5282

Open

Description

@egamma

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:

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; done

On 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 listSessions is bounded (a p-limit style pool of 16 to 64), including the detached-detection pass, so the caller never holds more than a few dozen handles.
  • events.jsonl is read once per session; detached status and the first user message come from the same prefix read.
  • metadataLimit is effective on the default includeDetached: true path, with entries sorted by mtime first so the limit covers the most recent sessions.
  • The derived summary is persisted (in workspace.yaml or in the existing session-store.db) so later listings do not open events.jsonl at all.
  • A prune or retention option exists for ~/.copilot/session-state (copilot sessions currently has only import).

Additional context

Activity

  1. added theissue type on Oct 28, 2026
  2. added
    area:sessionsSession management, resume, history, session picker, and session state
    and removed on Oct 29, 2026
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

    area:sessionsSession management, resume, history, session picker, and session state

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions