This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge PR #35: keep listSessions from deleting state for live daemons (Closes #34)

listSessions() had two cleanup paths that ran regardless of whether
the recorded pid was still alive:

1. The .sock branch deleted the socket file whenever isSocketReachable
failed (500ms probe trips on busy daemon, transient EAGAIN, race
with a service restart). Once .sock was gone, the still-alive
daemon became invisible to every future scan with no recovery
short of kill -9.

2. The .json branch unconditionally deleted metadata older than 24h.
Long-lived daemons whose metadata wasn't refreshed (because nothing
refreshes it) had their .json removed after a day even though the
process kept consuming memory.

Both paths now gate on isProcessAlive(pid). If the pid is alive, the
state is kept. The TTL is meant to age out known-dead sessions, not
unknown-state ones; the kill(pid, 0) probe is one cheap syscall per
dead-looking session per scan.

Reported and fixed by @schickling-assistant. Includes regression
tests in tests/list-filters.test.ts that exercise both the live-pid
guard and the matched negative case where the 24h TTL still fires
when the pid is dead.

Nathan Herald (May 3, 2026, 10:11 AM +0200) d08d20ed 0dc1c8f5

+69 -4
+17 -4
src/sessions.ts
··· 361 361 // reachable socket can briefly coexist with exitedAt being set. 362 362 const status = metadata?.exitedAt ? "exited" : "running"; 363 363 sessions.push({ name, socketPath, pid, status, metadata }); 364 + } else if (pid !== null && isProcessAlive(pid)) { 365 + // Pid is still alive but the socket isn't reachable right now (busy 366 + // daemon, transient EAGAIN, race with a service restart). Keep the 367 + // socket on disk and report the session as running — deleting the 368 + // socket would render the still-alive daemon permanently invisible. 369 + const metadata = readMetadata(name); 370 + const status = metadata?.exitedAt ? "exited" : "running"; 371 + sessions.push({ name, socketPath, pid, status, metadata }); 364 372 } else { 365 - // Process died — clean up socket/pid but keep metadata 373 + // Process really died — clean up socket/pid but keep metadata 366 374 cleanupSocket(name); 367 375 } 368 376 } ··· 383 391 // this keys off exitedAt; for vanished sessions (no exit record written) 384 392 // fall back to createdAt so they don't accumulate indefinitely. A session 385 393 // with a missing daemon and a metadata file older than 24h is not coming 386 - // back regardless of why it died. 394 + // back regardless of why it died — *unless* the recorded pid is still 395 + // alive, in which case the daemon outlived its socket and we must keep 396 + // metadata around so the session stays addressable. 387 397 const ageAnchor = metadata.exitedAt ?? metadata.createdAt; 388 398 if (ageAnchor) { 389 399 const anchoredAt = new Date(ageAnchor).getTime(); 390 400 if (Date.now() - anchoredAt > DEAD_SESSION_TTL_MS) { 391 - cleanupAll(name); 392 - continue; 401 + const pid = readPid(name); 402 + if (pid === null || !isProcessAlive(pid)) { 403 + cleanupAll(name); 404 + continue; 405 + } 393 406 } 394 407 } 395 408
+52
tests/list-filters.test.ts
··· 155 155 }, 15000); 156 156 }); 157 157 158 + describe("listSessions guards against deleting state for live daemons", () => { 159 + // Refs https://github.com/myobie/pty/issues/34. listSessions used to 160 + // unconditionally `cleanupSocket` whenever the socket-reachable probe 161 + // failed and `cleanupAll` whenever metadata was older than 24h. Both 162 + // ran even if the recorded pid was still alive — once the .sock or 163 + // .json was gone, the still-running daemon became invisible to every 164 + // future scan. These tests pin the new behaviour: live pid wins. 165 + it("keeps a session whose socket file is missing but recorded pid is still alive", () => { 166 + const dir = makeSessionDir(); 167 + const name = uniqueName(); 168 + // Use the test runner's own pid as a stand-in for an alive daemon. 169 + fs.writeFileSync(path.join(dir, `${name}.pid`), String(process.pid)); 170 + fs.writeFileSync(path.join(dir, `${name}.json`), JSON.stringify({ 171 + command: "cat", args: [], displayCommand: "cat", cwd: os.tmpdir(), 172 + createdAt: new Date().toISOString(), 173 + })); 174 + // Note: no .sock file written. Without the guard, scan-and-cleanup paths 175 + // would fall into the .json branch and delete metadata-on-age. 176 + 177 + // Force the .json into the >24h bucket so the second guard is exercised. 178 + const old = new Date(Date.now() - 48 * 3600_000).toISOString(); 179 + fs.writeFileSync(path.join(dir, `${name}.json`), JSON.stringify({ 180 + command: "cat", args: [], displayCommand: "cat", cwd: os.tmpdir(), 181 + createdAt: old, 182 + })); 183 + 184 + const r = runCli(dir, "list", "--json"); 185 + expect(r.status).toBe(0); 186 + const found = JSON.parse(r.stdout).find((s: any) => s.name === name); 187 + expect(found, "session should still be listed because its pid is alive").toBeDefined(); 188 + // Metadata file must survive the call so the next scan also sees it. 189 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(true); 190 + }, 10000); 191 + 192 + it("does delete metadata older than 24h when the pid is dead", () => { 193 + const dir = makeSessionDir(); 194 + const name = uniqueName(); 195 + // Pid 0x7fffffff is "guaranteed dead" on Linux/macOS in practice. 196 + fs.writeFileSync(path.join(dir, `${name}.pid`), "2147483647"); 197 + const old = new Date(Date.now() - 48 * 3600_000).toISOString(); 198 + fs.writeFileSync(path.join(dir, `${name}.json`), JSON.stringify({ 199 + command: "cat", args: [], displayCommand: "cat", cwd: os.tmpdir(), 200 + createdAt: old, 201 + })); 202 + 203 + const r = runCli(dir, "list", "--json"); 204 + expect(r.status).toBe(0); 205 + expect(JSON.parse(r.stdout).find((s: any) => s.name === name)).toBeUndefined(); 206 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(false); 207 + }, 10000); 208 + }); 209 + 158 210 describe("pty list --status", () => { 159 211 it("filters to a single status", async () => { 160 212 const dir = makeSessionDir();