Skip to content

Commit 65b26e3

Browse files
frenchie4111claude
andcommitted
Fix auto-update hang by force-exiting stuck PTY cleanup
When quitAndInstall fires, Squirrel's ShipIt sanity-checks that the target app has actually exited before swapping the bundle. If any PTY child (stuck claude, shell with background jobs) ignores SIGHUP, the main process hangs draining the fd, Squirrel aborts, and the old version relaunches. Later the zombie process crashes and macOS shows the "unexpectedly quit" dialog. - updater:quitAndInstall now SIGKILLs PTYs, strips the before-quit listener, kicks off ShipIt, and force-exits after 1.5s - before-quit (Cmd+Q path) keeps the graceful SIGHUP but arms a 1.5s watchdog so a stuck child can't hang the process indefinitely - pty-manager kill/killAll accept an optional signal and tolerate already-dead PTYs - dev:debug script now watches the real dev userData dir (Harness (Dev)) and cleans up its tail on exit via trap Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 71e651a commit 65b26e3

3 files changed

Lines changed: 56 additions & 7 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"description": "Harness - manage multiple Claude Code instances across git worktrees",
1111
"scripts": {
1212
"dev": "electron-vite dev",
13-
"dev:debug": "electron-vite dev & sleep 2 && tail -f ~/Library/Application\\ Support/harness/debug.log",
13+
"dev:debug": "sh -c 'trap \"kill 0\" EXIT; tail -F \"$HOME/Library/Application Support/Harness (Dev)/debug.log\" & electron-vite dev'",
1414
"log": "tail -f ~/Library/Application\\ Support/harness/debug.log",
1515
"log:clear": "rm -f ~/Library/Application\\ Support/harness/debug.log",
1616
"build": "electron-vite build",

src/main/index.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,37 @@ function registerIpcHandlers(): void {
636636
})
637637

638638
ipcMain.handle('updater:quitAndInstall', () => {
639-
autoUpdater.quitAndInstall()
639+
log('updater', 'quitAndInstall requested — cleaning up for fast exit')
640+
try {
641+
stopWatchingStatus?.()
642+
stopWatchingStatus = null
643+
} catch (err) {
644+
log('updater', 'stopWatchingStatus failed', err instanceof Error ? err.message : String(err))
645+
}
646+
try {
647+
ptyManager.killAll('SIGKILL')
648+
} catch (err) {
649+
log('updater', 'ptyManager.killAll failed', err instanceof Error ? err.message : String(err))
650+
}
651+
try {
652+
sealAllActive()
653+
saveConfigSync(config)
654+
} catch (err) {
655+
log('updater', 'final persistence failed', err instanceof Error ? err.message : String(err))
656+
}
657+
658+
// Skip our before-quit handler — it's already been done above, and it can
659+
// hang waiting for PTY fds to drain. We just want Squirrel to see us gone.
660+
app.removeAllListeners('before-quit')
661+
662+
// Hard-exit fallback. If Squirrel/Electron's quit takes longer than ~1.5s,
663+
// force-kill the process so ShipIt's "target still running" check passes.
664+
setTimeout(() => {
665+
log('updater', 'fallback app.exit(0) — Squirrel should take over')
666+
app.exit(0)
667+
}, 1500)
668+
669+
autoUpdater.quitAndInstall(true, false)
640670
return true
641671
})
642672

@@ -842,9 +872,24 @@ app.on('window-all-closed', () => {
842872
}
843873
})
844874

875+
let quitWatchdogArmed = false
845876
app.on('before-quit', () => {
846877
stopWatchingStatus?.()
878+
stopWatchingStatus = null
847879
ptyManager.killAll()
848880
sealAllActive()
849881
saveConfigSync(config)
882+
883+
// Force-exit watchdog: if a PTY child (stuck claude / shell) won't die after
884+
// SIGHUP, the main process can hang indefinitely draining fds. Give the
885+
// graceful path ~1.5s, then hard-exit. electron-updater's ShipIt helper has
886+
// already been spawned by this point if an update is pending, so force-exit
887+
// is safe — ShipIt runs independently and swaps the bundle once we're gone.
888+
if (!quitWatchdogArmed) {
889+
quitWatchdogArmed = true
890+
setTimeout(() => {
891+
log('app', 'quit watchdog fired — force-exiting')
892+
app.exit(0)
893+
}, 1500).unref()
894+
}
850895
})

src/main/pty-manager.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,18 +82,22 @@ export class PtyManager {
8282
}
8383
}
8484

85-
kill(id: string): void {
86-
log('pty', `kill id=${id}`)
85+
kill(id: string, signal?: string): void {
86+
log('pty', `kill id=${id}${signal ? ` signal=${signal}` : ''}`)
8787
const instance = this.ptys.get(id)
8888
if (instance) {
89-
instance.pty.kill()
89+
try {
90+
instance.pty.kill(signal)
91+
} catch {
92+
// ignore — pty may already be dead
93+
}
9094
this.ptys.delete(id)
9195
}
9296
}
9397

94-
killAll(): void {
98+
killAll(signal?: string): void {
9599
for (const [id] of this.ptys) {
96-
this.kill(id)
100+
this.kill(id, signal)
97101
}
98102
}
99103

0 commit comments

Comments
 (0)