mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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 { render } from '@testing-library/react'
|
||||||
import { useScheduledTaskDesktopNotifications } from './useScheduledTaskDesktopNotifications'
|
import { useScheduledTaskDesktopNotifications } from './useScheduledTaskDesktopNotifications'
|
||||||
|
|
||||||
const { listMock, getRecentRunsMock, notifyDesktopMock } = vi.hoisted(() => ({
|
const { listMock, getRecentRunsMock, notifyDesktopMock, serverReadyMock } = vi.hoisted(() => ({
|
||||||
listMock: vi.fn(),
|
listMock: vi.fn(),
|
||||||
getRecentRunsMock: vi.fn(),
|
getRecentRunsMock: vi.fn(),
|
||||||
notifyDesktopMock: vi.fn(),
|
notifyDesktopMock: vi.fn(),
|
||||||
|
serverReadyMock: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../api/tasks', () => ({
|
vi.mock('../api/tasks', () => ({
|
||||||
@ -19,6 +20,10 @@ vi.mock('../lib/desktopNotifications', () => ({
|
|||||||
notifyDesktop: notifyDesktopMock,
|
notifyDesktop: notifyDesktopMock,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../lib/desktopRuntime', () => ({
|
||||||
|
whenDesktopServerReady: serverReadyMock,
|
||||||
|
}))
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
useScheduledTaskDesktopNotifications()
|
useScheduledTaskDesktopNotifications()
|
||||||
return null
|
return null
|
||||||
@ -32,6 +37,31 @@ describe('useScheduledTaskDesktopNotifications', () => {
|
|||||||
getRecentRunsMock.mockReset()
|
getRecentRunsMock.mockReset()
|
||||||
notifyDesktopMock.mockReset()
|
notifyDesktopMock.mockReset()
|
||||||
notifyDesktopMock.mockResolvedValue(true)
|
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 () => {
|
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 { useEffect } from 'react'
|
||||||
import { tasksApi } from '../api/tasks'
|
import { tasksApi } from '../api/tasks'
|
||||||
import { notifyDesktop } from '../lib/desktopNotifications'
|
import { notifyDesktop } from '../lib/desktopNotifications'
|
||||||
|
import { whenDesktopServerReady } from '../lib/desktopRuntime'
|
||||||
import type { CronTask, TaskRun } from '../types/task'
|
import type { CronTask, TaskRun } from '../types/task'
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 30_000
|
const POLL_INTERVAL_MS = 30_000
|
||||||
@ -68,6 +69,7 @@ export function useScheduledTaskDesktopNotifications(): void {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let stopped = false
|
let stopped = false
|
||||||
let initialized = false
|
let initialized = false
|
||||||
|
let interval: number | undefined
|
||||||
|
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
@ -107,14 +109,20 @@ export function useScheduledTaskDesktopNotifications(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void poll()
|
// Wait for the local server URL to be resolved and healthy before polling.
|
||||||
const interval = window.setInterval(() => {
|
// 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()
|
void poll()
|
||||||
}, POLL_INTERVAL_MS)
|
interval = window.setInterval(() => {
|
||||||
|
void poll()
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true
|
stopped = true
|
||||||
window.clearInterval(interval)
|
if (interval !== undefined) window.clearInterval(interval)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,34 @@ function getDetectedDesktopHost() {
|
|||||||
return getDesktopHost()
|
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() {
|
export function isDesktopRuntime() {
|
||||||
return getDetectedDesktopHost().isDesktop
|
return getDetectedDesktopHost().isDesktop
|
||||||
}
|
}
|
||||||
@ -139,6 +167,7 @@ export async function initializeDesktopServerUrl() {
|
|||||||
setBaseUrl(serverUrl)
|
setBaseUrl(serverUrl)
|
||||||
setAuthToken(null)
|
setAuthToken(null)
|
||||||
await waitForHealth(serverUrl)
|
await waitForHealth(serverUrl)
|
||||||
|
markDesktopServerReady()
|
||||||
return serverUrl
|
return serverUrl
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@ -182,6 +211,7 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
|
|||||||
|
|
||||||
if (!browserH5Runtime) {
|
if (!browserH5Runtime) {
|
||||||
await ensureBrowserApiAccessibleWithoutH5(requestedUrl)
|
await ensureBrowserApiAccessibleWithoutH5(requestedUrl)
|
||||||
|
markDesktopServerReady()
|
||||||
return requestedUrl
|
return requestedUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -209,6 +239,7 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markDesktopServerReady()
|
||||||
return requestedUrl
|
return requestedUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user