mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
+61
-29
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user