Skip to content

Commit e2726b4

Browse files
Ayomipo18Ayomipo Solaja
andauthored
Add: WebSocket endpoint for real-time run status (#11) (#140)
Adds /ws/runs/{run_id} so the dashboard receives status pushes instead of waiting up to 5s for the next poll. Because /api/predict runs inference inline, status transitions happen in the process serving the socket, so a small in-memory pub/sub hub is enough: the predict handlers publish a terminal event and every socket watching that run receives it. On connect the socket reads the current status from the database first, so a client attaching to an already-finished run still gets a terminal event rather than hanging. Frontend: useRunPolling now prefers a WebSocket per in-flight run and only starts the 5s interval when the socket path is unavailable, so behaviour degrades to exactly what it was before. Closes #11 Co-authored-by: Ayomipo Solaja <ayomiposolaja@users.noreply.github.com>
1 parent ce09074 commit e2726b4

7 files changed

Lines changed: 1024 additions & 7 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { renderHook, act } from '@testing-library/react'
3+
import { useRunPolling, POLL_INTERVAL_MS } from './useRunPolling'
4+
import type { Run } from '../api'
5+
6+
class FakeWebSocket {
7+
static instances: FakeWebSocket[] = []
8+
static readonly OPEN = 1
9+
static readonly CLOSED = 3
10+
11+
readyState = FakeWebSocket.OPEN
12+
onmessage: ((event: { data: string }) => void) | null = null
13+
onerror: (() => void) | null = null
14+
15+
constructor(public url: string) {
16+
FakeWebSocket.instances.push(this)
17+
}
18+
19+
close() {
20+
this.readyState = FakeWebSocket.CLOSED
21+
}
22+
23+
emit(payload: unknown) {
24+
this.onmessage?.({ data: JSON.stringify(payload) })
25+
}
26+
27+
fail() {
28+
this.onerror?.()
29+
}
30+
}
31+
32+
const originalWebSocket = globalThis.WebSocket
33+
34+
function run(id: number, status: Run['status']): Run {
35+
return {
36+
id,
37+
kind: 'demo',
38+
status,
39+
analysis_type: 'deforestation',
40+
created_at: '2026-01-01T00:00:00Z',
41+
updated_at: '2026-01-01T00:00:00Z',
42+
}
43+
}
44+
45+
beforeEach(() => {
46+
FakeWebSocket.instances = []
47+
vi.stubGlobal('WebSocket', FakeWebSocket)
48+
vi.useFakeTimers()
49+
})
50+
51+
afterEach(() => {
52+
vi.useRealTimers()
53+
vi.stubGlobal('WebSocket', originalWebSocket)
54+
})
55+
56+
describe('useRunPolling', () => {
57+
it('does not poll while the WebSocket path is healthy', () => {
58+
const fetchRuns = vi.fn()
59+
renderHook(() => useRunPolling([run(1, 'running')], fetchRuns))
60+
61+
act(() => {
62+
vi.advanceTimersByTime(POLL_INTERVAL_MS * 3)
63+
})
64+
65+
expect(fetchRuns).not.toHaveBeenCalled()
66+
})
67+
68+
it('refetches as soon as a run reports a terminal status', () => {
69+
const fetchRuns = vi.fn()
70+
renderHook(() => useRunPolling([run(1, 'running')], fetchRuns))
71+
72+
act(() => {
73+
FakeWebSocket.instances[0].emit({ type: 'status', run_id: 1, status: 'completed' })
74+
})
75+
76+
expect(fetchRuns).toHaveBeenCalledTimes(1)
77+
})
78+
79+
it('falls back to polling once a socket errors', () => {
80+
const fetchRuns = vi.fn()
81+
renderHook(() => useRunPolling([run(1, 'running')], fetchRuns))
82+
83+
// The error flips the hook to unhealthy, which starts the interval.
84+
// `act` flushes that state update synchronously, so no `waitFor` is
85+
// needed — and `waitFor` would deadlock against the fake timers here.
86+
act(() => {
87+
FakeWebSocket.instances[0].fail()
88+
})
89+
90+
act(() => {
91+
vi.advanceTimersByTime(POLL_INTERVAL_MS * 2)
92+
})
93+
94+
expect(fetchRuns).toHaveBeenCalledTimes(2)
95+
})
96+
97+
it('neither polls nor connects when nothing is running', () => {
98+
const fetchRuns = vi.fn()
99+
renderHook(() => useRunPolling([run(1, 'completed')], fetchRuns))
100+
101+
act(() => {
102+
vi.advanceTimersByTime(POLL_INTERVAL_MS * 3)
103+
})
104+
105+
expect(FakeWebSocket.instances).toHaveLength(0)
106+
expect(fetchRuns).not.toHaveBeenCalled()
107+
})
108+
109+
it('still reports the running → completed transition', () => {
110+
const onCompleted = vi.fn()
111+
const { rerender } = renderHook(
112+
({ runs }) => useRunPolling(runs, vi.fn(), onCompleted),
113+
{ initialProps: { runs: [run(1, 'running')] } },
114+
)
115+
116+
rerender({ runs: [run(1, 'completed')] })
117+
118+
expect(onCompleted).toHaveBeenCalledTimes(1)
119+
expect(onCompleted.mock.calls[0][0].id).toBe(1)
120+
})
121+
})

‎frontend/src/hooks/useRunPolling.ts‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,52 @@
1-
import { useEffect, useRef } from 'react'
1+
import { useCallback, useEffect, useMemo, useRef } from 'react'
22
import type { Run } from '../api'
3+
import { useRunWebSocket } from './useRunWebSocket'
34

5+
/** Interval used when falling back to polling, in milliseconds. */
6+
export const POLL_INTERVAL_MS = 5000
7+
8+
/**
9+
* Keep `runs` fresh while any of them is still running.
10+
*
11+
* Prefers a WebSocket per in-flight run and refetches as soon as one reports a
12+
* terminal status. Polling is only started when the WebSocket path is
13+
* unavailable — no WebSocket implementation, or a socket that errored — so the
14+
* hook degrades to its previous behaviour rather than losing updates.
15+
*/
416
export function useRunPolling(
517
runs: Run[],
618
fetchRuns: () => void,
719
onCompleted?: (run: Run) => void,
820
) {
921
const prevRunsRef = useRef<Map<number, string>>(new Map())
22+
const fetchRunsRef = useRef(fetchRuns)
23+
24+
useEffect(() => {
25+
fetchRunsRef.current = fetchRuns
26+
}, [fetchRuns])
27+
28+
const runningIds = useMemo(
29+
() => runs.filter((r) => r.status === 'running').map((r) => r.id),
30+
[runs],
31+
)
32+
33+
const handleTerminal = useCallback(() => {
34+
fetchRunsRef.current()
35+
}, [])
36+
37+
const { healthy } = useRunWebSocket(runningIds, handleTerminal)
1038

1139
useEffect(() => {
12-
const hasRunning = runs.some((r) => r.status === 'running')
13-
if (!hasRunning) return
40+
if (runningIds.length === 0) return
41+
// A healthy socket already pushes updates, so polling would be redundant.
42+
if (healthy) return
1443

1544
const interval = setInterval(() => {
16-
fetchRuns()
17-
}, 5000)
45+
fetchRunsRef.current()
46+
}, POLL_INTERVAL_MS)
1847

1948
return () => clearInterval(interval)
20-
}, [runs, fetchRuns])
49+
}, [runningIds, healthy])
2150

2251
// Detect transitions from running → completed
2352
useEffect(() => {
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { renderHook, act, waitFor } from '@testing-library/react'
3+
import {
4+
buildRunWebSocketUrl,
5+
isWebSocketSupported,
6+
useRunWebSocket,
7+
} from './useRunWebSocket'
8+
9+
/** Minimal scriptable WebSocket stand-in. */
10+
class FakeWebSocket {
11+
static instances: FakeWebSocket[] = []
12+
static readonly CONNECTING = 0
13+
static readonly OPEN = 1
14+
static readonly CLOSING = 2
15+
static readonly CLOSED = 3
16+
17+
readyState = FakeWebSocket.OPEN
18+
onmessage: ((event: { data: string }) => void) | null = null
19+
onerror: (() => void) | null = null
20+
closed = false
21+
22+
constructor(public url: string) {
23+
FakeWebSocket.instances.push(this)
24+
}
25+
26+
close() {
27+
this.closed = true
28+
this.readyState = FakeWebSocket.CLOSED
29+
}
30+
31+
emit(payload: unknown) {
32+
this.onmessage?.({ data: JSON.stringify(payload) })
33+
}
34+
35+
emitRaw(data: string) {
36+
this.onmessage?.({ data })
37+
}
38+
39+
fail() {
40+
this.onerror?.()
41+
}
42+
}
43+
44+
const originalWebSocket = globalThis.WebSocket
45+
46+
beforeEach(() => {
47+
FakeWebSocket.instances = []
48+
vi.stubGlobal('WebSocket', FakeWebSocket)
49+
})
50+
51+
afterEach(() => {
52+
vi.stubGlobal('WebSocket', originalWebSocket)
53+
vi.unstubAllEnvs()
54+
})
55+
56+
describe('buildRunWebSocketUrl', () => {
57+
it('derives a ws:// URL from the page origin when no API base is set', () => {
58+
expect(buildRunWebSocketUrl(7)).toBe(`${window.location.origin.replace(/^http/, 'ws')}/ws/runs/7`)
59+
})
60+
61+
it('upgrades an https API base to wss', () => {
62+
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com')
63+
expect(buildRunWebSocketUrl(3)).toBe('wss://api.example.com/ws/runs/3')
64+
})
65+
66+
it('does not double up the slash when the base has a trailing one', () => {
67+
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/')
68+
expect(buildRunWebSocketUrl(3)).toBe('wss://api.example.com/ws/runs/3')
69+
})
70+
})
71+
72+
describe('useRunWebSocket', () => {
73+
it('opens one socket per running run', () => {
74+
renderHook(() => useRunWebSocket([1, 2], vi.fn()))
75+
expect(FakeWebSocket.instances).toHaveLength(2)
76+
expect(FakeWebSocket.instances.map((s) => s.url)).toEqual([
77+
buildRunWebSocketUrl(1),
78+
buildRunWebSocketUrl(2),
79+
])
80+
})
81+
82+
it('opens nothing when there are no running runs', () => {
83+
renderHook(() => useRunWebSocket([], vi.fn()))
84+
expect(FakeWebSocket.instances).toHaveLength(0)
85+
})
86+
87+
it('invokes the callback on a terminal status', () => {
88+
const onTerminal = vi.fn()
89+
renderHook(() => useRunWebSocket([1], onTerminal))
90+
91+
act(() => {
92+
FakeWebSocket.instances[0].emit({ type: 'status', run_id: 1, status: 'completed' })
93+
})
94+
95+
expect(onTerminal).toHaveBeenCalledTimes(1)
96+
expect(onTerminal.mock.calls[0][0]).toMatchObject({ run_id: 1, status: 'completed' })
97+
})
98+
99+
it('treats a failed run as terminal', () => {
100+
const onTerminal = vi.fn()
101+
renderHook(() => useRunWebSocket([1], onTerminal))
102+
103+
act(() => {
104+
FakeWebSocket.instances[0].emit({
105+
type: 'status',
106+
run_id: 1,
107+
status: 'failed',
108+
error: 'boom',
109+
})
110+
})
111+
112+
expect(onTerminal).toHaveBeenCalledTimes(1)
113+
})
114+
115+
it('ignores a non-terminal status', () => {
116+
const onTerminal = vi.fn()
117+
renderHook(() => useRunWebSocket([1], onTerminal))
118+
119+
act(() => {
120+
FakeWebSocket.instances[0].emit({ type: 'status', run_id: 1, status: 'running' })
121+
})
122+
123+
expect(onTerminal).not.toHaveBeenCalled()
124+
})
125+
126+
it('survives an unparseable frame', () => {
127+
const onTerminal = vi.fn()
128+
renderHook(() => useRunWebSocket([1], onTerminal))
129+
130+
expect(() => {
131+
act(() => {
132+
FakeWebSocket.instances[0].emitRaw('not json')
133+
})
134+
}).not.toThrow()
135+
expect(onTerminal).not.toHaveBeenCalled()
136+
})
137+
138+
it('reports unhealthy once a socket errors', async () => {
139+
const { result } = renderHook(() => useRunWebSocket([1], vi.fn()))
140+
expect(result.current.healthy).toBe(true)
141+
142+
act(() => {
143+
FakeWebSocket.instances[0].fail()
144+
})
145+
146+
await waitFor(() => expect(result.current.healthy).toBe(false))
147+
})
148+
149+
it('reports unhealthy when the environment has no WebSocket', async () => {
150+
vi.stubGlobal('WebSocket', undefined)
151+
expect(isWebSocketSupported()).toBe(false)
152+
153+
const { result } = renderHook(() => useRunWebSocket([1], vi.fn()))
154+
await waitFor(() => expect(result.current.healthy).toBe(false))
155+
})
156+
157+
it('closes its sockets on unmount', () => {
158+
const { unmount } = renderHook(() => useRunWebSocket([1, 2], vi.fn()))
159+
unmount()
160+
expect(FakeWebSocket.instances.every((s) => s.closed)).toBe(true)
161+
})
162+
163+
it('does not reopen sockets when the id list is unchanged', () => {
164+
const { rerender } = renderHook(({ ids }) => useRunWebSocket(ids, vi.fn()), {
165+
initialProps: { ids: [1] },
166+
})
167+
expect(FakeWebSocket.instances).toHaveLength(1)
168+
169+
// A fresh array with equal contents must not churn the connection.
170+
rerender({ ids: [1] })
171+
expect(FakeWebSocket.instances).toHaveLength(1)
172+
})
173+
})

0 commit comments

Comments
 (0)