fix(desktop): gate scheduled-task notification poll on server readiness

The desktop notification poller fired on mount, racing the bootstrap that
resolves the dynamic server URL and confirms /health. Its first requests hit
the uninitialized default base URL and failed with "Failed to fetch", logging
spurious client_api_request_failed warnings to the diagnostics panel.

Add a whenDesktopServerReady() signal resolved once initializeDesktopServerUrl
sets the base URL and the healthcheck passes, and gate the poller on it so it
only starts once the server is reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-06-04 21:31:57 +08:00
parent 449ff0b0ff
commit 82e857163f
3 changed files with 74 additions and 5 deletions

View File

@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from '@testing-library/react'
import { useScheduledTaskDesktopNotifications } from './useScheduledTaskDesktopNotifications'
const { listMock, getRecentRunsMock, notifyDesktopMock } = vi.hoisted(() => ({
const { listMock, getRecentRunsMock, notifyDesktopMock, serverReadyMock } = vi.hoisted(() => ({
listMock: vi.fn(),
getRecentRunsMock: vi.fn(),
notifyDesktopMock: vi.fn(),
serverReadyMock: vi.fn(),
}))
vi.mock('../api/tasks', () => ({
@ -19,6 +20,10 @@ vi.mock('../lib/desktopNotifications', () => ({
notifyDesktop: notifyDesktopMock,
}))
vi.mock('../lib/desktopRuntime', () => ({
whenDesktopServerReady: serverReadyMock,
}))
function Harness() {
useScheduledTaskDesktopNotifications()
return null
@ -32,6 +37,31 @@ describe('useScheduledTaskDesktopNotifications', () => {
getRecentRunsMock.mockReset()
notifyDesktopMock.mockReset()
notifyDesktopMock.mockResolvedValue(true)
serverReadyMock.mockReset()
serverReadyMock.mockResolvedValue(undefined)
})
it('does not poll until the desktop server is ready', async () => {
let resolveReady: () => void = () => {}
serverReadyMock.mockReturnValue(
new Promise<void>((resolve) => {
resolveReady = resolve
}),
)
listMock.mockResolvedValue({ tasks: [] })
getRecentRunsMock.mockResolvedValue({ runs: [] })
render(<Harness />)
// While the server is not ready, the poller must stay silent — this is the
// regression guard for the startup race that logged "Failed to fetch" warnings.
await vi.advanceTimersByTimeAsync(60_000)
expect(listMock).not.toHaveBeenCalled()
expect(getRecentRunsMock).not.toHaveBeenCalled()
resolveReady()
await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1))
expect(listMock).toHaveBeenCalledTimes(1)
})
it('does not notify old runs on first poll and notifies new desktop-enabled task runs later', async () => {

View File

@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { tasksApi } from '../api/tasks'
import { notifyDesktop } from '../lib/desktopNotifications'
import { whenDesktopServerReady } from '../lib/desktopRuntime'
import type { CronTask, TaskRun } from '../types/task'
const POLL_INTERVAL_MS = 30_000
@ -68,6 +69,7 @@ export function useScheduledTaskDesktopNotifications(): void {
useEffect(() => {
let stopped = false
let initialized = false
let interval: number | undefined
const poll = async () => {
try {
@ -107,14 +109,20 @@ export function useScheduledTaskDesktopNotifications(): void {
}
}
void poll()
const interval = window.setInterval(() => {
// Wait for the local server URL to be resolved and healthy before polling.
// Firing immediately on mount would race the desktop bootstrap and hit an
// uninitialized base URL, producing benign "Failed to fetch" warnings.
void whenDesktopServerReady().then(() => {
if (stopped) return
void poll()
}, POLL_INTERVAL_MS)
interval = window.setInterval(() => {
void poll()
}, POLL_INTERVAL_MS)
})
return () => {
stopped = true
window.clearInterval(interval)
if (interval !== undefined) window.clearInterval(interval)
}
}, [])
}

View File

@ -38,6 +38,34 @@ function getDetectedDesktopHost() {
return getDesktopHost()
}
/**
* Server-readiness signal.
*
* The api client points at the default base URL until `initializeDesktopServerUrl`
* resolves the real (dynamic) server URL and confirms `/health`. Background pollers
* that fire on app mount (e.g. scheduled-task desktop notifications) must wait for
* this, otherwise their first requests hit an uninitialized base URL and fail with
* `TypeError: Failed to fetch` a benign startup race that nonetheless pollutes the
* diagnostics panel with `client_api_request_failed` warnings.
*/
let resolveServerReady: (() => void) | null = null
let serverReadyPromise: Promise<void> | null = null
/** Resolve once the desktop/browser server URL is initialized and healthy. */
export function whenDesktopServerReady(): Promise<void> {
if (!serverReadyPromise) {
serverReadyPromise = new Promise<void>((resolve) => {
resolveServerReady = resolve
})
}
return serverReadyPromise
}
function markDesktopServerReady() {
whenDesktopServerReady() // ensure the promise exists before resolving it
resolveServerReady?.()
}
export function isDesktopRuntime() {
return getDetectedDesktopHost().isDesktop
}
@ -139,6 +167,7 @@ export async function initializeDesktopServerUrl() {
setBaseUrl(serverUrl)
setAuthToken(null)
await waitForHealth(serverUrl)
markDesktopServerReady()
return serverUrl
} catch (error) {
const message =
@ -182,6 +211,7 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
if (!browserH5Runtime) {
await ensureBrowserApiAccessibleWithoutH5(requestedUrl)
markDesktopServerReady()
return requestedUrl
}
@ -209,6 +239,7 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
}
}
markDesktopServerReady()
return requestedUrl
}