From ac931283dbd6713a490b153c186110cd11b9c3bb Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Wed, 21 Oct 2026 16:42:06 -0700 Subject: [PATCH 1/2] fix(pty): end terminals one at a time (node-pty races two exits on Windows) Quitting or Update all with two or more sessions intermittently showed "Assertion failed: remove_pty_baton(baton->id)" from conpty.node, or left DevDeck hanging or crashing (0xC0000005) on the way out. node-pty's Windows exit threads remove a terminal from one shared std::vector without a lock (a mutex lands only in 0.15.0-beta); two terminals ending together corrupted it. PtyHost.kill now chains: the next terminal is killed once the previous exit has been reported (by then its thread has finished with the list), or after 4 s. Shutdown's default budget grows with the number of terminals (1.5 s each, at least 5 s). qa/claude-update.mjs failed at quit on 3/20 and 3/32 runs before; 0/50 after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NLGwiBY5uEivJqERQzcoY --- src/main/ptyHost.test.ts | 34 ++++++++++++++++++++++++++++------ src/main/ptyHost.ts | 29 ++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/main/ptyHost.test.ts b/src/main/ptyHost.test.ts index af7f7fc..11c1fa7 100644 --- a/src/main/ptyHost.test.ts +++ b/src/main/ptyHost.test.ts @@ -26,14 +26,14 @@ describe('PtyHost', () => { const work = host.shutdown(); expect(host.shutdown()).toBe(work); expect(a.kill).toHaveBeenCalledTimes(1); - expect(b.kill).toHaveBeenCalledTimes(1); + expect(b.kill).not.toHaveBeenCalled(); // waits for a to finish exiting expect(() => host.create('c', 'cmd', [], '.', 80, 24, () => {}, () => {})).toThrow('shutting down'); let finished = false; void work.then(() => { finished = true; }); - b.emitExit(0); - await Promise.resolve(); - expect(finished).toBe(false); a.emitExit(0); + await vi.waitFor(() => expect(b.kill).toHaveBeenCalledTimes(1)); + expect(finished).toBe(false); + b.emitExit(0); await work; expect(finished).toBe(true); }); @@ -71,7 +71,7 @@ describe('PtyHost', () => { expect(onData).toHaveBeenCalledWith('hello'); }); - it('write/resize/kill delegate to the right process; killAll kills everything', () => { + it('write/resize/kill delegate to the right process; killAll kills everything', async () => { const a = fakeProc(), b = fakeProc(); const spawn = vi.fn().mockReturnValueOnce(a).mockReturnValueOnce(b) as unknown as PtySpawn; const host = new PtyHost(spawn); @@ -80,7 +80,29 @@ describe('PtyHost', () => { host.write('a', 'x'); expect(a.write).toHaveBeenCalledWith('x'); host.resize('b', 120, 40); expect(b.resize).toHaveBeenCalledWith(120, 40); host.kill('a'); expect(a.kill).toHaveBeenCalled(); - host.killAll(); expect(b.kill).toHaveBeenCalled(); + host.killAll(); expect(b.kill).not.toHaveBeenCalled(); + a.emitExit(0); + await vi.waitFor(() => expect(b.kill).toHaveBeenCalled()); + }); + + it('ends terminals one at a time, each after the previous exit (node-pty races two exits on Windows)', async () => { + vi.useFakeTimers(); + try { + const procs = [fakeProc(), fakeProc(), fakeProc()]; + const spawn = vi.fn(); + for (const p of procs) spawn.mockReturnValueOnce(p); + const host = new PtyHost(spawn as unknown as PtySpawn); + for (const id of ['a', 'b', 'c']) host.create(id, 'pwsh', [], '.', 80, 24, () => {}, () => {}); + host.killAll(); + const killed = () => procs.map((p) => (p.kill as ReturnType).mock.calls.length); + expect(killed()).toEqual([1, 0, 0]); + procs[0].emitExit(0); + await vi.advanceTimersByTimeAsync(0); + expect(killed()).toEqual([1, 1, 0]); + // A terminal that never reports its exit holds the next one up only for a while. + await vi.advanceTimersByTimeAsync(4_000); + expect(killed()).toEqual([1, 1, 1]); + } finally { vi.useRealTimers(); } }); it('onExit fires the per-id callback and drops the process (write after exit is a no-op)', () => { diff --git a/src/main/ptyHost.ts b/src/main/ptyHost.ts index 39c7553..0841b75 100644 --- a/src/main/ptyHost.ts +++ b/src/main/ptyHost.ts @@ -78,6 +78,11 @@ export const MAX_SESSIONS = 32; /** How long `taskkill` may take before the plain kill goes ahead without it. */ const TREE_KILL_TIMEOUT_MS = 3_000; +/** How long one ending terminal may hold up the next before it goes ahead anyway. */ +const KILL_STEP_TIMEOUT_MS = 4_000; +/** Quitting waits at least this long for the terminals, and longer when there are many to end one by one. */ +const SHUTDOWN_PER_TERMINAL_MS = 1_500; + export type TreeKiller = (pid: number) => Promise; /** @@ -156,6 +161,8 @@ interface Session { prefixTruncated: boolean; /** Whether this terminal answers Claude Code's status probe (and so receives its status reports). */ programStatus: boolean; + /** Settles when the process has exited and node-pty has said so. */ + exit: Promise; } export class PtyHost { @@ -163,6 +170,8 @@ export class PtyHost { private pendingExits = new Set>(); private stopping = false; private shutdownWork: Promise | null = null; + /** The terminal being ended right now, until its exit lands; the next one waits for it. */ + private ending: Promise | null = null; constructor(private readonly spawn: PtySpawn) {} create( @@ -205,6 +214,7 @@ export class PtyHost { bufferedLength: 0, prefixTruncated: false, programStatus: answerProbe, + exit, }; this.sessions.set(id, session); proc.onData((data) => { @@ -319,7 +329,18 @@ export class PtyHost { const s = this.sessions.get(id); if (!s) return; this.sessions.delete(id); - s.proc.kill(); + // One at a time. On Windows, node-pty's exit threads remove a terminal from one shared list + // without a lock (fixed upstream only in a 0.15 beta), so two terminals ending at the same moment + // (quitting, Update all, closing a group) could corrupt it: an "Assertion failed: + // remove_pty_baton" dialog, or a crash on the way out. The next kill starts once the previous + // exit has been reported, and by then that thread is done with the list. + const step = (): Promise => { + try { s.proc.kill(); } catch (error) { console.error('DevDeck: terminal close failed', error); } + return withTimeout(s.exit, KILL_STEP_TIMEOUT_MS, 'terminal exit').catch(() => undefined); + }; + const next = this.ending ? this.ending.then(step) : step(); + this.ending = next; + void next.then(() => { if (this.ending === next) this.ending = null; }); } killAll(): void { for (const id of [...this.sessions.keys()]) { @@ -335,12 +356,14 @@ export class PtyHost { } /** Drain native exit callbacks while Electron's Node environment is still alive. */ - shutdown(timeoutMs = 5_000): Promise { + shutdown(timeoutMs?: number): Promise { if (this.shutdownWork) return this.shutdownWork; this.stopping = true; const exits = [...this.pendingExits]; // also includes terminals already closing this.killAll(); - this.shutdownWork = withTimeout(Promise.all(exits), timeoutMs, 'terminal shutdown').then(() => {}); + // Terminals end one by one (see kill), so many of them take longer than a few at once would. + const budget = timeoutMs ?? Math.max(5_000, exits.length * SHUTDOWN_PER_TERMINAL_MS); + this.shutdownWork = withTimeout(Promise.all(exits), budget, 'terminal shutdown').then(() => {}); return this.shutdownWork; } } From 5bdf54ac513fdc1239bcbb4d433f4188fa54d6da Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Wed, 21 Oct 2026 16:42:06 -0700 Subject: [PATCH 2/2] chore: 1.44.1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NLGwiBY5uEivJqERQzcoY --- README.md | 2 +- docs/releases/1.44.1.md | 15 +++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 docs/releases/1.44.1.md diff --git a/README.md b/README.md index 3e6566d..e2683cd 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ See what needs attention, resume the right conversation, and work on a paired PC -> **1.44.0** lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.md)). 1.43.0 runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md). +> **1.44.1** fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.md)). 1.43.0 runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md).
DevDeck project overview with repository and session navigation diff --git a/docs/releases/1.44.1.md b/docs/releases/1.44.1.md new file mode 100644 index 0000000..26f8744 --- /dev/null +++ b/docs/releases/1.44.1.md @@ -0,0 +1,15 @@ +# DevDeck 1.44.1 + +## No more "Assertion failed: remove_pty_baton" on quit or Update all + +On Windows, closing DevDeck, or running **Update all**, with two or more sessions open could show a Microsoft Visual C++ Runtime Library dialog: `Assertion failed! … conpty.node … Expression: remove_pty_baton(baton->id)`. Other times DevDeck kept running in the background after its window closed, or exited with a crash. + +- **Cause:** the terminal library DevDeck uses (node-pty) keeps its open terminals in one list. When a terminal ends, a background thread removes it from that list, without a lock. Two terminals ending at the same moment edited the list at once, and one of them could no longer find itself. A fix exists upstream only in a beta. +- **Fix:** DevDeck now ends its terminals one at a time. Each starts ending only after the previous one has reported its exit, by which time its thread is done with the list. This applies wherever terminals are ended: quitting, Update all, closing a group, and closing a single session. +- **Quitting with many sessions:** this takes up to about 1.5 seconds per session, instead of a fixed 5 seconds. A terminal that never reports its exit holds up the next one for at most 4 seconds. +- **Measured:** the automated Update-all check stopped or crashed at quit on 3 of 20 and 3 of 32 runs before. After the fix it passed 50 of 50. + +## Also + +- **Accessibility:** the sidebar's buttons are now named by what they show, in the same order, for voice control (WCAG 2.5.3). The group toggles, session rows and project rows are affected. axe-core 4.14 checks this. +- **Development:** esbuild 0.28.2, playwright 1.64.1, axe-core 4.14.0. Test runs no longer leave their temp folders behind. diff --git a/package-lock.json b/package-lock.json index 97e8900..591306b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devdeck", - "version": "1.44.0", + "version": "1.44.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devdeck", - "version": "1.44.0", + "version": "1.44.1", "license": "MIT", "dependencies": { "@fontsource-variable/geist": "5.3.0", diff --git a/package.json b/package.json index 5788f79..552abf1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devdeck", - "version": "1.44.0", + "version": "1.44.1", "description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume", "main": "dist/main/main.js", "type": "commonjs",