diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index 50d973621df..3b112ba31a6 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -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 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) diff --git a/src/main/updater.mac-install.test.ts b/src/main/updater.mac-install.test.ts index b47b3181aeb..2026b354edc 100644 --- a/src/main/updater.mac-install.test.ts +++ b/src/main/updater.mac-install.test.ts @@ -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. diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index a9ff22a5bf4..14454f8a78b 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -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((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')) diff --git a/src/main/updater.ts b/src/main/updater.ts index 6d03d8a7c3e..0d383880088 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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 | null = null +let updateCheckSilentSettleTimer: ReturnType | 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 => 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 | 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 => autoUpdater.checkForUpdates() + const launch = (): Promise | 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,