mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
449ff0b0ff
commit
82e857163f
@ -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 () => {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user