From 82e857163fb6b4e85523fea0c42bba9e4922d58a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 4 Jun 2026 21:31:57 +0800 Subject: [PATCH] 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) --- ...ScheduledTaskDesktopNotifications.test.tsx | 32 ++++++++++++++++++- .../useScheduledTaskDesktopNotifications.ts | 16 +++++++--- desktop/src/lib/desktopRuntime.ts | 31 ++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx b/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx index 02c991de..de6a415a 100644 --- a/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx +++ b/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx @@ -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((resolve) => { + resolveReady = resolve + }), + ) + listMock.mockResolvedValue({ tasks: [] }) + getRecentRunsMock.mockResolvedValue({ runs: [] }) + + render() + + // 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 () => { diff --git a/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts b/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts index 2cebd0c0..358a4394 100644 --- a/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts +++ b/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts @@ -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) } }, []) } diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index 0f57a53f..a21287f4 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -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 | null = null + +/** Resolve once the desktop/browser server URL is initialized and healthy. */ +export function whenDesktopServerReady(): Promise { + if (!serverReadyPromise) { + serverReadyPromise = new Promise((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 }