Track update check attempts and handle stalls (#5904)

* Track update check attempts to prevent stale races and handle stalls

- Sequence each update check with an attempt ID to prevent stale event handlers (like async changelog fetches) from overwriting newer check states.
- Introduce a 45-second timeout to gracefully fail or retry hung update checks that stall before reaching updater events.
- Settle checks with a brief grace period when the updater promise resolves without firing a terminal event.

* Ignore update-available events when no active check attempt exists

Prevents processing stale events from the auto-updater after a silent
background check has already settled, which could otherwise overwrite
more recent check states or trigger unexpected status transitions.

* Ignore stale electron-updater events from previous check attempts

Introduce attempt tracking to bind emitted updater events to the active
update check. This prevents stale events (such as checking, available,
not available, or errors) from previous checks or preflight stages
from triggering false state transitions or duplicate status updates.

- Track the launched and active attempt IDs during update preflight
- Ignore events if their attempt ID does not match the active check
- Allow error events to propagate during active download/install phases
- Clear pending nudge campaigns on silent follow-up checks settling to not-available
This commit is contained in:
Jinjing
2026-06-20 16:39:42 -07:00
committed by GitHub
parent 404e45f022
commit fe7cf01c51
4 changed files with 977 additions and 57 deletions
+61 -29
View File
@@ -23,10 +23,16 @@ type UpdaterHandlerContext = {
getPublishingWindowLastGoodCheck: () => { lastGoodTag: string } | null
getMissingManifestPrereleaseFallbackUserInitiated: () => boolean | null
getCurrentStatus: () => UpdateStatus
getActiveUpdateCheckEventAttemptId: () => number | null
getKnownReleaseUrl: () => string | undefined
getPendingInstallVersion: () => string
getUserInitiatedCheck: () => boolean
hasNewerDownloadedVersion: () => boolean
shouldHandleUpdaterErrorEvent: () => boolean
clearUpdateAvailableEventPending: (attemptId: number | null) => void
isActiveUpdateCheckAttempt: (attemptId: number) => boolean
markUpdateCheckEventAttempt: () => boolean
markUpdateAvailableEventPending: (attemptId: number | null) => void
markMissingManifestPrereleaseFallbackChecking: () => void
performQuitAndInstall: () => void | Promise<void>
recordCompletedUpdateCheck: () => void
@@ -54,10 +60,16 @@ export function registerAutoUpdaterHandlers({
getPublishingWindowLastGoodCheck,
getMissingManifestPrereleaseFallbackUserInitiated,
getCurrentStatus,
getActiveUpdateCheckEventAttemptId,
getKnownReleaseUrl,
getPendingInstallVersion,
getUserInitiatedCheck,
hasNewerDownloadedVersion,
shouldHandleUpdaterErrorEvent,
clearUpdateAvailableEventPending,
isActiveUpdateCheckAttempt,
markUpdateCheckEventAttempt,
markUpdateAvailableEventPending,
markMissingManifestPrereleaseFallbackChecking,
performQuitAndInstall,
recordCompletedUpdateCheck,
@@ -112,6 +124,9 @@ export function registerAutoUpdaterHandlers({
})
autoUpdater.on('checking-for-update', () => {
if (!markUpdateCheckEventAttempt()) {
return
}
clearBackgroundCheckLaunchPending()
resetMacInstallState()
clearAvailableUpdateContext()
@@ -122,6 +137,10 @@ export function registerAutoUpdaterHandlers({
})
autoUpdater.on('update-available', (info) => {
const attemptId = getActiveUpdateCheckEventAttemptId()
if (attemptId === null) {
return
}
clearBackgroundCheckLaunchPending()
// --- synchronous preamble (runs before any await) ---
const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult()
@@ -149,43 +168,53 @@ export function registerAutoUpdaterHandlers({
// Why: fetching changelog in the main process avoids CORS issues that
// would block a renderer-side fetch to onorca.dev, and ensures the
// card can render immediately without an async loading gap.
markUpdateAvailableEventPending(attemptId)
void (async () => {
const changelog = await fetchChangelog(info.version, app.getVersion()).catch(() => null)
try {
const changelog = await fetchChangelog(info.version, app.getVersion()).catch(() => null)
// Why: the handler is now async, so up to 5 seconds may pass during the
// fetch. If another autoUpdater event (e.g., 'error') fired and updated
// currentStatus during that window, broadcasting 'available' here would
// overwrite a more recent status. Guard against this by checking that the
// state hasn't advanced past the point where 'available' makes sense.
if (getCurrentStatus().state !== 'checking' && getCurrentStatus().state !== 'idle') {
return
}
// --- post-await side effects (only run if the guard passed) ---
// Why: these must live AFTER the guard, not before the await. If the
// fetch times out and a concurrent 'error' event advanced the status,
// bailing out above avoids orphaned side effects — e.g., availableVersion
// set without a matching 'available' broadcast, or a completed-check
// timestamp persisted for a check that never showed a result.
setAvailableVersion(info.version)
setAvailableReleaseUrl(null)
if (missingManifestFallback || publishingWindowLastGoodCheck) {
// Why: offering a previous/last-good release is only a temporary
// fallback; keep probing soon so users can move to the newest tag once
// its platform manifest finishes publishing.
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
} else {
recordCompletedUpdateCheck()
if (!wasUserInitiated) {
scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
// Why: the handler is now async, so up to 5 seconds may pass during the
// fetch. If another autoUpdater event (e.g., 'error') fired and updated
// the attempt during that window, broadcasting 'available' here would
// overwrite a more recent check. Guard on the attempt before state.
if (!isActiveUpdateCheckAttempt(attemptId)) {
return
}
if (getCurrentStatus().state !== 'checking' && getCurrentStatus().state !== 'idle') {
return
}
}
sendStatus({ state: 'available', version: info.version, changelog })
// --- post-await side effects (only run if the guard passed) ---
// Why: these must live AFTER the guard, not before the await. If the
// fetch times out and a concurrent 'error' event advanced the status,
// bailing out above avoids orphaned side effects — e.g., availableVersion
// set without a matching 'available' broadcast, or a completed-check
// timestamp persisted for a check that never showed a result.
setAvailableVersion(info.version)
setAvailableReleaseUrl(null)
if (missingManifestFallback || publishingWindowLastGoodCheck) {
// Why: offering a previous/last-good release is only a temporary
// fallback; keep probing soon so users can move to the newest tag once
// its platform manifest finishes publishing.
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
} else {
recordCompletedUpdateCheck()
if (!wasUserInitiated) {
scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
}
}
sendStatus({ state: 'available', version: info.version, changelog })
} finally {
clearUpdateAvailableEventPending(attemptId)
}
})()
})
autoUpdater.on('update-not-available', () => {
if (getActiveUpdateCheckEventAttemptId() === null) {
return
}
clearBackgroundCheckLaunchPending()
resetMacInstallState()
const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult()
@@ -245,6 +274,9 @@ export function registerAutoUpdaterHandlers({
if (shouldSuppressMissingManifestPrereleaseFallbackEvent(message, err)) {
return
}
if (!shouldHandleUpdaterErrorEvent()) {
return
}
clearBackgroundCheckLaunchPending()
resetMacInstallState()
suppressMissingManifestPrereleaseFallbackPromiseFailure(message)
+12
View File
@@ -145,6 +145,10 @@ describe('updater mac install handoff', () => {
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
// Why: the update-available handler is now async (it awaits fetchChangelog).
// Flush microtasks so setAvailableVersion runs before update-downloaded fires.
@@ -193,6 +197,10 @@ describe('updater mac install handoff', () => {
const { setupAutoUpdater, quitAndInstall } = await import('./updater')
setupAutoUpdater(mainWindow as never, { onBeforeQuit })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await vi.advanceTimersByTimeAsync(0)
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
@@ -271,6 +279,10 @@ describe('updater mac install handoff', () => {
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
// Why: the update-available handler is now async (it awaits fetchChangelog).
// Flush microtasks so setAvailableVersion runs before update-downloaded fires.
+608 -1
View File
@@ -112,8 +112,12 @@ vi.mock('./ipc/pty', () => ({
killAllPty: killAllPtyMock
}))
const { fetchChangelogMock } = vi.hoisted(() => ({
fetchChangelogMock: vi.fn()
}))
vi.mock('./updater-changelog', () => ({
fetchChangelog: vi.fn().mockResolvedValue(null)
fetchChangelog: fetchChangelogMock
}))
const { fetchNudgeMock, shouldApplyNudgeMock } = vi.hoisted(() => ({
@@ -157,6 +161,7 @@ describe('updater', () => {
powerMonitorOnMock.mockReset()
fetchNudgeMock.mockReset().mockResolvedValue(null)
shouldApplyNudgeMock.mockReset().mockReturnValue(false)
fetchChangelogMock.mockReset().mockResolvedValue(null)
fetchNewerReleaseTagsMock.mockReset().mockResolvedValue([])
vi.unstubAllGlobals()
vi.useRealTimers()
@@ -356,6 +361,7 @@ describe('updater', () => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-not-available')
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
@@ -363,6 +369,476 @@ describe('updater', () => {
})
})
it('keeps a silent background settle user-initiated after menu promotion', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => null })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(0)
sendMock.mockClear()
checkForUpdatesFromMenu()
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
await vi.advanceTimersByTimeAsync(1000)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
it('settles a manual check when electron-updater resolves without a terminal event', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const setLastUpdateCheckAt = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setLastUpdateCheckAt
})
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
expect(setLastUpdateCheckAt).toHaveBeenCalledTimes(1)
})
it('ignores a stale update-available event after a silent background settle', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt: vi.fn()
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await vi.advanceTimersByTimeAsync(0)
expect(fetchChangelogMock).not.toHaveBeenCalled()
expect(sendMock).not.toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'available', version: '1.0.61' })
)
})
it('ignores a stale checking-for-update event after a silent manual settle', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
sendMock.mockClear()
autoUpdaterMock.emit('checking-for-update')
expect(sendMock).not.toHaveBeenCalled()
})
it('ignores stale updater events while a new check is still in feed preflight', async () => {
vi.useFakeTimers()
let resolveSecondTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
fetchNewerReleaseTagsMock
.mockResolvedValueOnce({ tags: [], state: 'no-newer' })
.mockImplementationOnce(
() =>
new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => {
resolveSecondTags = resolve
})
)
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(2)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-not-available')
expect(sendMock).not.toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
resolveSecondTags({ tags: [], state: 'no-newer' })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-not-available')
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
it('does not let a stale silent settle finish a later manual check', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
})
await vi.advanceTimersByTimeAsync(1000)
expect(sendMock).not.toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
it('does not let a stale pending update-available block a later silent settle', async () => {
vi.useFakeTimers()
let resolveChangelog: (value: null) => void = () => {}
fetchChangelogMock.mockImplementation(
() =>
new Promise<null>((resolve) => {
resolveChangelog = resolve
})
)
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.52'], state: 'ready' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-available', { version: '1.0.52' })
})
}
return Promise.resolve(undefined)
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(fetchChangelogMock).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('error', new Error('boom'))
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: 'boom',
userInitiated: undefined
})
})
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
})
await vi.advanceTimersByTimeAsync(1000)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
sendMock.mockClear()
resolveChangelog(null)
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).not.toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'available', version: '1.0.52' })
)
})
it('ignores a stale update-available event after a new check starts preflight', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) {
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
})
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await vi.advanceTimersByTimeAsync(0)
expect(fetchChangelogMock).not.toHaveBeenCalled()
expect(sendMock).not.toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'available', version: '1.0.61' })
)
})
it('ignores a stale update-not-available event after a new check starts preflight', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
if (autoUpdaterMock.checkForUpdates.mock.calls.length === 1) {
return Promise.resolve(undefined)
}
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
})
autoUpdaterMock.emit('update-not-available')
expect(sendMock).not.toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
it('ignores a stale error event after a new check starts preflight', async () => {
vi.useFakeTimers()
let resolveSecondTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
fetchNewerReleaseTagsMock
.mockResolvedValueOnce({ tags: [], state: 'no-newer' })
.mockImplementationOnce(
() =>
new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => {
resolveSecondTags = resolve
})
)
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
sendMock.mockClear()
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(2)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('error', new Error('stale boom'))
expect(sendMock).not.toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'error', message: 'stale boom' })
)
resolveSecondTags({ tags: [], state: 'no-newer' })
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-not-available')
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'not-available',
userInitiated: true
})
})
it('times out a manual preflight that never reaches electron-updater events', async () => {
vi.useFakeTimers()
fetchNewerReleaseTagsMock.mockImplementation(() => new Promise(() => {}))
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'checking',
userInitiated: true
})
await vi.advanceTimersByTimeAsync(45 * 1000)
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: 'Update check timed out. Try again in a few minutes.',
userInitiated: true
})
})
it('does not launch electron-updater after a manual preflight timeout settles', async () => {
vi.useFakeTimers()
let resolveTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
fetchNewerReleaseTagsMock.mockImplementation(
() =>
new Promise<{ tags: string[]; state: 'no-newer' }>((resolve) => {
resolveTags = resolve
})
)
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.advanceTimersByTimeAsync(45 * 1000)
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'error',
message: 'Update check timed out. Try again in a few minutes.',
userInitiated: true
})
resolveTags({ tags: [], state: 'no-newer' })
await vi.advanceTimersByTimeAsync(0)
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
})
it('runs a fresh prerelease check when Shift-click promotes an in-flight stable check', async () => {
let resolveStableTags: (value: { tags: string[]; state: 'no-newer' }) => void = () => {}
fetchNewerReleaseTagsMock
@@ -540,6 +1016,45 @@ describe('updater', () => {
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)
})
it('still surfaces updater error events while a download is in flight', async () => {
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: ['v1.0.61'], state: 'ready' })
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
queueMicrotask(() => {
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
})
return Promise.resolve(undefined)
})
autoUpdaterMock.downloadUpdate.mockImplementation(() => {
autoUpdaterMock.emit('error', new Error('download failed'))
return new Promise(() => {})
})
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater, checkForUpdatesFromMenu, downloadUpdate } = await import('./updater')
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
checkForUpdatesFromMenu()
await vi.waitFor(() => {
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.0.61',
changelog: null
})
})
sendMock.mockClear()
downloadUpdate()
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1)
expect(sendMock).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'error', message: 'download failed' })
)
})
it('defers quitAndInstall through the shared main-process entrypoint', async () => {
vi.useFakeTimers()
@@ -858,6 +1373,7 @@ describe('updater', () => {
.map(([, status]) => status)
expect(statusCalls).toContainEqual({ state: 'checking', userInitiated: true })
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
autoUpdaterMock.emit('update-available', { version: '1.0.62' })
@@ -891,6 +1407,9 @@ describe('updater', () => {
getDismissedUpdateNudgeId: () => null
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await new Promise((resolve) => setTimeout(resolve, 0))
@@ -1003,6 +1522,44 @@ describe('updater', () => {
expect(setDismissedUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
})
it('clears pending nudge campaign when a silent follow-up check settles not-available', async () => {
vi.useFakeTimers()
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
let pendingNudgeId: string | null = null
const setPendingUpdateNudgeId = vi.fn((id: string | null) => {
pendingNudgeId = id
})
const setDismissedUpdateNudgeId = vi.fn()
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setPendingUpdateNudgeId,
getPendingUpdateNudgeId: () => pendingNudgeId,
getDismissedUpdateNudgeId: () => null,
setDismissedUpdateNudgeId
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
await vi.advanceTimersByTimeAsync(1000)
expect(sendMock).toHaveBeenCalledWith('updater:status', { state: 'not-available' })
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null)
expect(setDismissedUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
expect(pendingNudgeId).toBe(null)
})
it('auto-dismisses nudge campaign when the follow-up check errors out', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
@@ -1371,6 +1928,56 @@ describe('updater', () => {
})
})
it('keeps silent publishing-window fallback on the short retry cadence', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-24T21:40:00Z'))
appMock.getVersion.mockReturnValue('1.4.26')
fetchNewerReleaseTagsMock
.mockResolvedValueOnce({
tags: [],
state: 'not-ready',
lastGoodTag: 'v1.4.26'
})
.mockResolvedValueOnce(['v1.4.27'])
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
const setLastUpdateCheckAt = vi.fn()
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt
})
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
await vi.advanceTimersByTimeAsync(1000)
expect(sendMock).toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ state: 'not-available' })
)
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(60 * 1000)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.4.27'
})
})
it('keeps background checks retryable while newer release assets are still publishing', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-24T21:40:00Z'))
+296 -27
View File
@@ -36,6 +36,8 @@ const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000
const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000
const QUIT_AND_INSTALL_DELAY_MS = 100
const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500
const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000
const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000
let mainWindowRef: BrowserWindow | null = null
let currentStatus: UpdateStatus = { state: 'idle' }
@@ -60,6 +62,13 @@ let backgroundCheckLaunchPending = false
// Why: a manually promoted background check can emit an error event before the
// paired promise catch runs; keep the promotion attached to that launch.
let backgroundCheckPromotedToUserInitiated = false
let updateCheckStallTimer: ReturnType<typeof setTimeout> | null = null
let updateCheckSilentSettleTimer: ReturnType<typeof setTimeout> | null = null
let updateCheckAttemptSequence = 0
let activeUpdateCheckAttemptId: number | null = null
let activeUpdateCheckLaunchAttemptId: number | null = null
let activeUpdateCheckEventAttemptId: number | null = null
let updateAvailableEventPendingAttemptId: number | null = null
let pendingPrereleaseUserInitiatedCheckAfterInFlight = false
let activeUpdateNudgeId: string | null = null
let awaitingNudgeCheckOutcome = false
@@ -194,6 +203,10 @@ function sendStatus(status: UpdateStatus): void {
const decoratedStatus = decorateStatusWithActiveNudge(status)
if (isUpdateCheckResultState(status.state)) {
finishActiveUpdateCheckAttempt()
}
if (
status.state === 'idle' ||
status.state === 'not-available' ||
@@ -240,6 +253,220 @@ function clearBackgroundCheckLaunchPending(): void {
backgroundCheckLaunchPending = false
}
function clearUpdateCheckStallTimer(): void {
if (!updateCheckStallTimer) {
return
}
clearTimeout(updateCheckStallTimer)
updateCheckStallTimer = null
}
function clearUpdateCheckSilentSettleTimer(): void {
if (!updateCheckSilentSettleTimer) {
return
}
clearTimeout(updateCheckSilentSettleTimer)
updateCheckSilentSettleTimer = null
}
function clearUpdateCheckTimers(): void {
clearUpdateCheckStallTimer()
clearUpdateCheckSilentSettleTimer()
}
function finishActiveUpdateCheckAttempt(): void {
activeUpdateCheckAttemptId = null
activeUpdateCheckLaunchAttemptId = null
activeUpdateCheckEventAttemptId = null
clearUpdateCheckTimers()
}
function getActiveUpdateCheckEventAttemptId(): number | null {
if (activeUpdateCheckAttemptId === null) {
return null
}
if (activeUpdateCheckEventAttemptId !== activeUpdateCheckAttemptId) {
return null
}
return activeUpdateCheckAttemptId
}
function isActiveUpdateCheckAttempt(attemptId: number): boolean {
return activeUpdateCheckAttemptId === attemptId
}
function markUpdateCheckEventAttempt(): boolean {
if (activeUpdateCheckAttemptId === null) {
return false
}
if (activeUpdateCheckLaunchAttemptId !== activeUpdateCheckAttemptId) {
return false
}
activeUpdateCheckEventAttemptId = activeUpdateCheckAttemptId
return true
}
function markUpdateCheckLaunched(attemptId: number): void {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return
}
activeUpdateCheckLaunchAttemptId = attemptId
}
function markUpdateAvailableEventPending(attemptId: number | null): void {
updateAvailableEventPendingAttemptId = attemptId
}
function clearUpdateAvailableEventPending(attemptId: number | null): void {
if (updateAvailableEventPendingAttemptId !== attemptId) {
return
}
updateAvailableEventPendingAttemptId = null
}
function armUpdateCheckStallTimer(attemptId: number): void {
clearUpdateCheckStallTimer()
updateCheckStallTimer = setTimeout(() => {
updateCheckStallTimer = null
if (!isActiveUpdateCheckAttempt(attemptId)) {
return
}
const wasUserInitiated = getSettledCheckUserInitiated()
if (currentStatus.state === 'checking') {
finishActiveUpdateCheckAttempt()
backgroundCheckLaunchPending = false
backgroundCheckPromotedToUserInitiated = false
userInitiatedCheck = false
void sendCheckFailureStatus(
'Update check timed out. Try again in a few minutes.',
wasUserInitiated,
'promise'
)
return
}
if (backgroundCheckLaunchPending) {
finishActiveUpdateCheckAttempt()
backgroundCheckLaunchPending = false
backgroundCheckPromotedToUserInitiated = false
userInitiatedCheck = false
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
}
}, UPDATE_CHECK_STALL_TIMEOUT_MS)
}
function beginUpdateCheckAttempt(): number {
finishActiveUpdateCheckAttempt()
updateAvailableEventPendingAttemptId = null
updateCheckAttemptSequence += 1
activeUpdateCheckAttemptId = updateCheckAttemptSequence
armUpdateCheckStallTimer(activeUpdateCheckAttemptId)
return activeUpdateCheckAttemptId
}
function rearmActiveUpdateCheckStallTimer(): void {
if (activeUpdateCheckAttemptId === null) {
return
}
armUpdateCheckStallTimer(activeUpdateCheckAttemptId)
}
function getSettledCheckUserInitiated(): boolean | undefined {
return userInitiatedCheck || backgroundCheckPromotedToUserInitiated || undefined
}
function isUpdateCheckResultState(state: UpdateStatus['state']): boolean {
return (
state === 'idle' ||
state === 'not-available' ||
state === 'available' ||
state === 'error' ||
state === 'downloading' ||
state === 'downloaded'
)
}
function consumeSilentCheckShortRetryReason(): boolean {
if (publishingWindowLastGoodCheck !== null) {
return true
}
return consumeMissingManifestPrereleaseFallbackResult() !== null
}
function completeSilentUpdateCheck(userInitiated: boolean | undefined): boolean {
const shouldRetrySoon = consumeSilentCheckShortRetryReason()
clearAvailableUpdateContext()
if (shouldRetrySoon) {
// Why: a silent result against a temporary last-good feed is still part of
// a release transition, so it must not suppress the short publish retry.
scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
return true
}
recordCompletedUpdateCheck()
if (!userInitiated) {
scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
}
return false
}
function settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | undefined): void {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return
}
if (updateAvailableEventPendingAttemptId === attemptId) {
return
}
if (currentStatus.state !== 'checking') {
if (backgroundCheckLaunchPending) {
finishActiveUpdateCheckAttempt()
clearBackgroundCheckLaunchPending()
backgroundCheckPromotedToUserInitiated = false
userInitiatedCheck = false
const shouldRetrySoon = completeSilentUpdateCheck(userInitiated)
if (awaitingNudgeCheckOutcome) {
if (shouldRetrySoon) {
deferPendingUpdateNudgeUntilRetry()
return
}
sendStatus({ state: 'not-available', userInitiated })
}
}
return
}
finishActiveUpdateCheckAttempt()
clearBackgroundCheckLaunchPending()
backgroundCheckPromotedToUserInitiated = false
userInitiatedCheck = false
completeSilentUpdateCheck(userInitiated)
sendStatus({ state: 'not-available', userInitiated })
}
function handleSettledUpdateCheckPromise(attemptId: number): void {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return
}
clearUpdateCheckSilentSettleTimer()
// Why: electron-updater can resolve its promise before the terminal event
// reaches our handlers. Give that event a short grace period, then unstick
// checks that genuinely resolved without one.
updateCheckSilentSettleTimer = setTimeout(() => {
updateCheckSilentSettleTimer = null
settleSilentUpdateCheck(attemptId, getSettledCheckUserInitiated())
}, UPDATE_CHECK_SILENT_SETTLE_DELAY_MS)
}
function shouldHandleUpdaterErrorEvent(): boolean {
if (getActiveUpdateCheckEventAttemptId() !== null) {
return true
}
// Why: electron-updater emits check errors globally. Once a check has
// settled, only active download/install flows should keep consuming errors.
return (
downloadInFlight ||
currentStatus.state === 'downloading' ||
currentStatus.state === 'downloaded'
)
}
function sendErrorStatus(message: string, userInitiated?: boolean): void {
if (
currentStatus.state === 'error' &&
@@ -630,6 +857,10 @@ function retryPrereleaseFallbackAfterMissingManifest(
) {
return false
}
const attemptId = activeUpdateCheckAttemptId
if (attemptId === null) {
return false
}
// Why: a published tag can briefly point at a missing platform manifest
// during GitHub release transitions. Walk back once to the previous feed
@@ -650,17 +881,25 @@ function retryPrereleaseFallbackAfterMissingManifest(
autoUpdater.setFeedURL({ provider: 'generic', url })
userInitiatedCheck = Boolean(userInitiated)
backgroundCheckLaunchPending = !userInitiated
void autoUpdater.checkForUpdates().catch((err) => {
const message = String(err?.message ?? err)
if (userInitiated) {
userInitiatedCheck = false
} else {
backgroundCheckLaunchPending = false
}
markMissingManifestPrereleaseFallbackPromiseHandled(message)
consumeMissingManifestPrereleaseFallbackResult()
void sendCheckFailureStatus(message, userInitiated, 'fallback-promise', err)
})
armUpdateCheckStallTimer(attemptId)
markUpdateCheckLaunched(attemptId)
void autoUpdater
.checkForUpdates()
.then(() => handleSettledUpdateCheckPromise(attemptId))
.catch((err) => {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return
}
const message = String(err?.message ?? err)
if (userInitiated) {
userInitiatedCheck = false
} else {
backgroundCheckLaunchPending = false
}
markMissingManifestPrereleaseFallbackPromiseHandled(message)
consumeMissingManifestPrereleaseFallbackResult()
void sendCheckFailureStatus(message, userInitiated, 'fallback-promise', err)
})
return true
}
@@ -686,21 +925,32 @@ function runBackgroundUpdateCheck(
// that gap without persisting a successful-check timestamp before the result.
backgroundCheckLaunchPending = true
backgroundCheckPromotedToUserInitiated = false
const attemptId = beginUpdateCheckAttempt()
// Don't send 'checking' here — the 'checking-for-update' event handler does it,
// and sending it from both places causes duplicate notifications (issue #35).
const autoUpdater = getAutoUpdater()
const launch = (): Promise<unknown> => autoUpdater.checkForUpdates()
const run = pinDefaultReleaseFeed().then(launch)
void Promise.resolve(run).catch((err) => {
const wasUserInitiated =
userInitiatedCheck || backgroundCheckPromotedToUserInitiated || undefined
backgroundCheckLaunchPending = false
backgroundCheckPromotedToUserInitiated = false
if (wasUserInitiated) {
userInitiatedCheck = false
const launch = (): Promise<unknown> | undefined => {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return undefined
}
void sendCheckFailureStatus(String(err?.message ?? err), wasUserInitiated, 'promise', err)
})
markUpdateCheckLaunched(attemptId)
return autoUpdater.checkForUpdates()
}
const run = pinDefaultReleaseFeed().then(launch)
void Promise.resolve(run)
.then(() => handleSettledUpdateCheckPromise(attemptId))
.catch((err) => {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return
}
const wasUserInitiated = getSettledCheckUserInitiated()
backgroundCheckLaunchPending = false
backgroundCheckPromotedToUserInitiated = false
if (wasUserInitiated) {
userInitiatedCheck = false
}
void sendCheckFailureStatus(String(err?.message ?? err), wasUserInitiated, 'promise', err)
})
}
export function checkForUpdates(): void {
@@ -758,6 +1008,7 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
sendStatus({ state: 'checking', userInitiated: true })
if (checkAlreadyInFlight) {
backgroundCheckPromotedToUserInitiated = true
rearmActiveUpdateCheckStallTimer()
if (options?.includePrerelease) {
// Why: the in-flight check may have already pinned the stable feed.
// Queue a fresh RC check so Shift-click doesn't inherit a stable result.
@@ -766,13 +1017,25 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
return
}
const attemptId = beginUpdateCheckAttempt()
const autoUpdater = getAutoUpdater()
const launch = (): Promise<unknown> => autoUpdater.checkForUpdates()
const launch = (): Promise<unknown> | undefined => {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return undefined
}
markUpdateCheckLaunched(attemptId)
return autoUpdater.checkForUpdates()
}
const run = pinDefaultReleaseFeed().then(launch)
void Promise.resolve(run).catch((err) => {
userInitiatedCheck = false
void sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err)
})
void Promise.resolve(run)
.then(() => handleSettledUpdateCheckPromise(attemptId))
.catch((err) => {
if (!isActiveUpdateCheckAttempt(attemptId)) {
return
}
userInitiatedCheck = false
void sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err)
})
}
export function isQuittingForUpdate(): boolean {
@@ -949,12 +1212,18 @@ export function setupAutoUpdater(
consumeMissingManifestPrereleaseFallbackResult,
getMissingManifestPrereleaseFallbackUserInitiated,
getPublishingWindowLastGoodCheck,
getActiveUpdateCheckEventAttemptId,
getCurrentStatus: () => currentStatus,
getKnownReleaseUrl,
getPendingInstallVersion,
getUserInitiatedCheck: () => userInitiatedCheck,
hasNewerDownloadedVersion,
shouldHandleUpdaterErrorEvent,
performQuitAndInstall,
clearUpdateAvailableEventPending,
isActiveUpdateCheckAttempt,
markUpdateCheckEventAttempt,
markUpdateAvailableEventPending,
sendCheckFailureStatus,
sendErrorStatus,
markMissingManifestPrereleaseFallbackChecking,