Skip to content

Commit 9fe41fc

Browse files
committed
fix-ota-again (squashed)
1 parent 26a6294 commit 9fe41fc

3 files changed

Lines changed: 88 additions & 35 deletions

File tree

src/main/index.ts

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { app, BrowserWindow, ipcMain, dialog, Menu, shell } from 'electron'
1+
import { app, autoUpdater as nativeAutoUpdater, BrowserWindow, ipcMain, dialog, Menu, shell } from 'electron'
22
import { autoUpdater } from 'electron-updater'
33
import { existsSync, readdirSync, statSync } from 'fs'
44
import { homedir } from 'os'
@@ -705,14 +705,19 @@ function registerIpcHandlers(): void {
705705
})
706706

707707
ipcMain.handle('updater:quitAndInstall', () => {
708-
log('updater', 'quitAndInstall requested — cleaning up for fast exit')
708+
log('updater', 'quitAndInstall requested — tearing down before handing off to Squirrel')
709709
try {
710710
stopWatchingStatus?.()
711711
stopWatchingStatus = null
712712
} catch (err) {
713713
log('updater', 'stopWatchingStatus failed', err instanceof Error ? err.message : String(err))
714714
}
715715
try {
716+
// Kill the whole PTY process group (zsh + claude + any grandchildren),
717+
// not just the direct shell child. Leaving descendants alive keeps
718+
// libuv handles attached on our side, which makes Electron's quit
719+
// sequence hang — and Squirrel.Mac then bails with "original process
720+
// did not end" before ShipIt swaps the bundle.
716721
ptyManager.killAll('SIGKILL')
717722
} catch (err) {
718723
log('updater', 'ptyManager.killAll failed', err instanceof Error ? err.message : String(err))
@@ -724,18 +729,10 @@ function registerIpcHandlers(): void {
724729
log('updater', 'final persistence failed', err instanceof Error ? err.message : String(err))
725730
}
726731

727-
// Skip our before-quit handler — it's already been done above, and it can
728-
// hang waiting for PTY fds to drain. We just want Squirrel to see us gone.
732+
// Skip our before-quit handler — we just did its work above.
729733
app.removeAllListeners('before-quit')
730734

731-
// Hard-exit fallback. If Squirrel/Electron's quit takes longer than ~1.5s,
732-
// force-kill the process so ShipIt's "target still running" check passes.
733-
setTimeout(() => {
734-
log('updater', 'fallback app.exit(0) — Squirrel should take over')
735-
app.exit(0)
736-
}, 1500)
737-
738-
autoUpdater.quitAndInstall(true, false)
735+
autoUpdater.quitAndInstall(true, true)
739736
return true
740737
})
741738

@@ -888,10 +885,23 @@ function setupAutoUpdater(): void {
888885
broadcastToAllWindows('updater:status', { state: 'downloaded', version: info.version })
889886
})
890887

891-
// Check on startup, then every 10 minutes
892-
autoUpdater.checkForUpdatesAndNotify().catch((err) => log('updater', 'check failed', err.message))
888+
// Also log native Squirrel.Mac errors. electron-updater wraps Squirrel via
889+
// its own MacUpdater but doesn't surface errors from the native side, so
890+
// things like "target app still running" or codesign mismatches would
891+
// otherwise be invisible. These are the errors that would have diagnosed
892+
// previous OTA loops in one glance.
893+
if (process.platform === 'darwin') {
894+
nativeAutoUpdater.on('error', (err) => {
895+
log('updater', `[error] Squirrel.Mac: ${err.message}`)
896+
})
897+
}
898+
899+
// Check on startup, then every 10 minutes. We use checkForUpdates (not
900+
// checkForUpdatesAndNotify) so there's no native OS notification — the
901+
// renderer shows an in-app banner based on the updater:status events.
902+
autoUpdater.checkForUpdates().catch((err) => log('updater', 'check failed', err.message))
893903
setInterval(() => {
894-
autoUpdater.checkForUpdatesAndNotify().catch(() => {})
904+
autoUpdater.checkForUpdates().catch(() => {})
895905
}, 10 * 60 * 1000)
896906
}
897907

@@ -951,24 +961,14 @@ app.on('window-all-closed', () => {
951961
}
952962
})
953963

954-
let quitWatchdogArmed = false
955964
app.on('before-quit', () => {
956965
stopWatchingStatus?.()
957966
stopWatchingStatus = null
958-
ptyManager.killAll()
967+
// SIGKILL the whole PTY process group so zsh + claude + grandchildren
968+
// all die immediately and release their libuv handles. Without this the
969+
// main process can hang draining fds and Squirrel.Mac will abort an
970+
// in-flight bundle swap with "original process did not end".
971+
ptyManager.killAll('SIGKILL')
959972
sealAllActive()
960973
saveConfigSync(config)
961-
962-
// Force-exit watchdog: if a PTY child (stuck claude / shell) won't die after
963-
// SIGHUP, the main process can hang indefinitely draining fds. Give the
964-
// graceful path ~1.5s, then hard-exit. electron-updater's ShipIt helper has
965-
// already been spawned by this point if an update is pending, so force-exit
966-
// is safe — ShipIt runs independently and swaps the bundle once we're gone.
967-
if (!quitWatchdogArmed) {
968-
quitWatchdogArmed = true
969-
setTimeout(() => {
970-
log('app', 'quit watchdog fired — force-exiting')
971-
app.exit(0)
972-
}, 1500).unref()
973-
}
974974
})

src/main/pty-manager.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,29 @@ export class PtyManager {
9696
kill(id: string, signal?: string): void {
9797
log('pty', `kill id=${id}${signal ? ` signal=${signal}` : ''}`)
9898
const instance = this.ptys.get(id)
99-
if (instance) {
99+
if (!instance) return
100+
this.ptys.delete(id)
101+
102+
const pid = instance.pty.pid
103+
// node-pty calls setsid() for each spawn, so the shell is its own
104+
// process-group leader and PGID === pid. Signalling -pid delivers to
105+
// the whole group (zsh + claude + any descendants) in one syscall,
106+
// instead of leaving grandchildren attached to our libuv handles.
107+
if (pid && pid > 0) {
100108
try {
101-
instance.pty.kill(signal)
109+
process.kill(-pid, (signal as NodeJS.Signals) || 'SIGKILL')
102110
} catch {
103-
// ignore — pty may already be dead
111+
// Group may already be dead.
104112
}
105-
this.ptys.delete(id)
113+
}
114+
try {
115+
// Belt & suspenders: close the master fd and let node-pty tear down
116+
// its own handles. Without this, the read stream on the master can
117+
// keep Electron's quit sequence waiting even after the descendants
118+
// are gone.
119+
instance.pty.kill(signal)
120+
} catch {
121+
// pty already dead
106122
}
107123
}
108124

src/renderer/App.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
2-
import type { Worktree, TerminalTab, PtyStatus, PRStatus, QuestStep, WorkspacePane, PendingWorktree } from './types'
2+
import type { Worktree, TerminalTab, PtyStatus, PRStatus, QuestStep, WorkspacePane, PendingWorktree, UpdaterStatus } from './types'
33
import type { Action } from './hotkeys'
44
import { resolveHotkeys } from './hotkeys'
55
import { HotkeysProvider } from './components/Tooltip'
@@ -84,6 +84,8 @@ export default function App(): JSX.Element {
8484
// Populated whenever the worktree list refreshes.
8585
const worktreeRepoRef = useRef<Record<string, string>>({})
8686
const [hooksConsent, setHooksConsent] = useState<'pending' | 'accepted' | 'declined'>('pending')
87+
const [updaterStatus, setUpdaterStatus] = useState<UpdaterStatus | null>(null)
88+
const [updateBannerDismissed, setUpdateBannerDismissed] = useState(false)
8789
const [sidebarVisible, setSidebarVisible] = useState(true)
8890
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
8991
const saved = Number(localStorage.getItem('harness:sidebarWidth'))
@@ -615,6 +617,20 @@ const setQuestStep = useCallback((next: QuestStep) => {
615617
})()
616618
}, [worktrees])
617619

620+
// Subscribe to auto-updater status for the in-app update banner.
621+
useEffect(() => {
622+
const cleanup = window.api.onUpdaterStatus((status) => {
623+
setUpdaterStatus(status)
624+
// If a new version shows up after a prior dismiss, re-show the banner.
625+
if (status.state === 'downloaded') setUpdateBannerDismissed(false)
626+
})
627+
return cleanup
628+
}, [])
629+
630+
const handleUpdateRestart = useCallback(() => {
631+
void window.api.quitAndInstall()
632+
}, [])
633+
618634
const handleAddRepo = useCallback(async () => {
619635
const root = await window.api.addRepo()
620636
if (root) {
@@ -1357,6 +1373,27 @@ const setQuestStep = useCallback((next: QuestStep) => {
13571373
return (
13581374
<HotkeysProvider bindings={resolvedHotkeys}>
13591375
<div className="flex h-full flex-col">
1376+
{/* Update-ready banner */}
1377+
{updaterStatus?.state === 'downloaded' && !updateBannerDismissed && (
1378+
<div className="bg-success/15 border-b border-success/30 pl-20 pr-4 py-2.5 drag-region flex items-center gap-3 shrink-0">
1379+
<span className="text-success text-sm flex-1">
1380+
Harness {updaterStatus.version} is ready to install. Restart to update.
1381+
</span>
1382+
<button
1383+
onClick={handleUpdateRestart}
1384+
className="px-3 py-1 bg-success/30 hover:bg-success/40 rounded text-sm text-success transition-colors shrink-0 cursor-pointer no-drag"
1385+
>
1386+
Restart &amp; install
1387+
</button>
1388+
<button
1389+
onClick={() => setUpdateBannerDismissed(true)}
1390+
className="px-3 py-1 text-success/80 hover:text-success text-sm transition-colors shrink-0 cursor-pointer no-drag"
1391+
>
1392+
Later
1393+
</button>
1394+
</div>
1395+
)}
1396+
13601397
{/* Hooks consent banner */}
13611398
{hooksConsent === 'pending' && (
13621399
<div className="bg-warning/15 border-b border-warning/30 pl-20 pr-4 py-2.5 drag-region flex items-center gap-3 shrink-0">

0 commit comments

Comments
 (0)