ListPeers misses live uds sessions — updatePidFile is non-atomic read-modify-write

Resolved 💬 2 comments Opened Mar 30, 2026 by dmossing Closed May 4, 2026

Symptom

ListPeers returns sessions in the bridge-only list even when they have a live uds socket in /tmp/cc-socks/. Affected sessions are unreachable via uds: and the only fallback (bridge:) triggers a Remote Control permission dialog.

Root cause

updatePidFile in src/utils/concurrentSessions.ts (observed at sha fae5242, version 2.1.89-dev.20260330) does a non-atomic read-modify-write:

async function updatePidFile(patch) {
  let pidFile = join(getSessionsDir(), `${process.pid}.json`);
  let data = jsonParse(await readFile(pidFile, "utf8"));
  await writeFile(pidFile, jsonStringify({ ...data, ...patch }));
}

Two failure modes observed

1. Lost-update race — Concurrent updatePidFile calls (e.g., updateSessionName racing updateSessionActivity) both read stale data; the second write clobbers the first. Sessions lose their name field → ListPeers drops them from the named uds list.

2. Null-byte truncation — Sessions that crash mid-writeFile leave files ending ...sock",<20-24 null bytes>. Observed on 3 of 13 session files:

| File | Size | Nulls | Valid bytes | Missing fields |
|---|---|---|---|---|
| 66766.json | 241 | 24 | 217 | name, status, updatedAt, bridgeSessionId |
| 73234.json | 233 | 20 | 213 | same |
| 512175.json | 230 | 21 | 209 | same |

All three truncate immediately after messagingSocketPath's trailing comma. Possibly Bun's writeFile pre-sizes (fallocate/ftruncate) before filling, and the crash lands between.

Corroborating evidence

Nudge's ~/.claude/slack_sessions/*.json has the same failure pattern — slack_sync_debug.log shows repeated "Error reading session file .../hook_*.json: Expecting value: line 1 column 1 (char 0)". Same non-atomic writer pattern likely.

Fix

Atomic tmp+rename — saveSessionIndex in the same file already does this correctly:

async function updatePidFile(patch) {
  let pidFile = join(getSessionsDir(), `${process.pid}.json`);
  let tmp = `${pidFile}.${Date.now()}.tmp`;
  let data = jsonParse(await readFile(pidFile, "utf8"));
  await writeFile(tmp, jsonStringify({ ...data, ...patch }));
  await rename(tmp, pidFile);  // atomic on same fs
}

This fixes the null-byte case. The lost-update race additionally needs either in-process serialization (a mutex around updatePidFile since it's always the same pid) or ListPeers robustness (see below).

Secondary

ListPeers should tolerate parse failures — fall back to /proc/<pid>/cwd when the JSON is bad:

for s in /tmp/cc-socks/*.sock; do
  pid=${s##*/}; pid=${pid%.sock}
  readlink /proc/$pid/cwd
done

This is what users currently do manually to discover sessions ListPeers misses.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗