Skip to content

Commit 6b1b346

Browse files
fix(frontend): security scan UI stuck on 'Scanning…' though backend job completed (MCP-2740) (#698)
* fix(frontend): reconcile security-scan UI against authoritative backend status The Server Detail → Security tab could stay stuck on "Scanning… N/N complete" with the Report button disabled indefinitely while the backend job was already `completed`. `scanLoading` was only cleared inside a live poll tick whose job id matched `activeScanJobId`. A sub-2s scan can finalize (and be replaced by a Pass-2 job, or have its id drift) before the first 2s tick, so the matched-job branch never fired and the spinner never cleared. Derive terminal state from the authoritative backend status on every status fetch, independent of `activeScanJobId` / `scan_pass` bookkeeping: - New pure, unit-tested reducer `frontend/src/utils/scanState.ts` (`isTerminalScanStatus`, `decideScanReconcile`). - Poll tick and initial `loadScanReport` both finalize on a terminal status (or a newer running Pass-2 job) regardless of job-id mismatch. - `loadScanReport` clears a stale `scanLoading` when the latest job is already terminal (handles a scan that finished while the component was unmounted). - Render-independent safety-net watcher so the spinner can never outlive a terminal status. - Add `data-test` hooks (scan-button, scan-progress, scan-report-link) for robust Playwright assertions. Verified with vitest unit tests and a Playwright sweep against the built (embedded) UI that reproduces the sub-2s / mismatched-job-id race; report under specs/mcp-2740-scan-ui-stuck/verification/. Related MCP-2740 * fix(MCP-2740): skip polling on post-finalize reload to prevent Pass-2 re-hiding Pass-1 report loadScanReport(true) called from finalizeScan and the safety-net watcher could restart polling when the backend was already running a Pass-2 job, re-enabling scanLoading and hiding the just-completed Pass-1 report. Fix: add skipPolling parameter to loadScanReport; callers that invoke it after clearScanRunState pass skipPolling=true so a concurrent Pass-2 job does not hijack the scan-loading state. Adds unit test documenting the post-finalize cleared-state invariant. Codex P2 finding on PR #698. Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent b99af57 commit 6b1b346

9 files changed

Lines changed: 416 additions & 37 deletions

File tree

frontend/src/utils/scanState.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Security-scan UI state reconciliation (MCP-2740).
2+
//
3+
// The Server Detail → Security tab previously cleared its "Scanning…" spinner
4+
// (`scanLoading`) only inside a live poll tick whose job id matched the tracked
5+
// `activeScanJobId`. A scan can finish in <2s — finalizing (and being replaced by
6+
// a Pass-2 job) before the first 2s tick — so the matched-job branch never fired
7+
// and the UI stayed stuck on "Scanning… N/N complete" with the Report button
8+
// disabled indefinitely. The backend job, meanwhile, was already `completed`.
9+
//
10+
// The fix derives terminal state from the authoritative backend status on EVERY
11+
// status fetch, independent of `activeScanJobId` / `scan_pass` bookkeeping. This
12+
// module holds that decision as a pure, unit-testable reducer.
13+
14+
export const TERMINAL_SCAN_STATUSES = [
15+
'completed',
16+
'complete',
17+
'failed',
18+
'error',
19+
'cancelled',
20+
'canceled',
21+
] as const
22+
23+
/** True when a backend scan status means the job is finished (no longer running). */
24+
export function isTerminalScanStatus(status?: string | null): boolean {
25+
return !!status && (TERMINAL_SCAN_STATUSES as readonly string[]).includes(status)
26+
}
27+
28+
export interface ScanReconcileInput {
29+
/** Backend job status (e.g. "running", "completed", "failed"). */
30+
status?: string | null
31+
/** Backend job id for the polled status. */
32+
jobId?: string | null
33+
/** Scan pass number (1 or 2); may be absent on older payloads. */
34+
scanPass?: number | null
35+
}
36+
37+
export interface ScanReconcileState {
38+
scanLoading: boolean
39+
activeScanJobId: string | null
40+
}
41+
42+
export interface ScanReconcileDecision {
43+
/** Finalize the UI: stop polling, clear the spinner, surface the report. */
44+
finalize: boolean
45+
/** The finalize is due to a failed/errored job (set error, skip success toast). */
46+
isError: boolean
47+
/** The job is still running/pending and polling should (continue to) run. */
48+
resumePolling: boolean
49+
}
50+
51+
/**
52+
* Decide how the UI should reconcile against an authoritative backend scan status.
53+
*
54+
* Pure and idempotent: a terminal status always finalizes, regardless of whether
55+
* the polled job id matches the tracked active job or which scan pass it is. A
56+
* newer Pass-2 job that is merely running also finalizes, because that means the
57+
* tracked Pass-1 work is done.
58+
*/
59+
export function decideScanReconcile(
60+
input: ScanReconcileInput,
61+
state: ScanReconcileState,
62+
): ScanReconcileDecision {
63+
const status = input.status ?? null
64+
65+
// Authoritative terminal status — finalize regardless of job-id / scan_pass.
66+
if (isTerminalScanStatus(status)) {
67+
return {
68+
finalize: true,
69+
isError: status === 'failed' || status === 'error',
70+
resumePolling: false,
71+
}
72+
}
73+
74+
// A different, newer Pass-2 job is running → the tracked Pass-1 job is done.
75+
if (
76+
state.activeScanJobId &&
77+
input.jobId &&
78+
input.jobId !== state.activeScanJobId &&
79+
input.scanPass === 2
80+
) {
81+
return { finalize: true, isError: false, resumePolling: false }
82+
}
83+
84+
// Still in flight.
85+
return {
86+
finalize: false,
87+
isError: false,
88+
resumePolling: status === 'running' || status === 'pending',
89+
}
90+
}

frontend/src/views/ServerDetail.vue

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,7 @@
966966
@click="startSecurityScan"
967967
:disabled="scanLoading || !dockerAvailable"
968968
class="btn btn-primary"
969+
data-test="scan-button"
969970
>
970971
<span v-if="scanLoading" class="loading loading-spinner loading-xs"></span>
971972
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -1011,7 +1012,7 @@
10111012
</div>
10121013

10131014
<!-- Scan Progress (visible during active scan) -->
1014-
<div v-if="scanLoading" class="space-y-3">
1015+
<div v-if="scanLoading" class="space-y-3" data-test="scan-progress">
10151016
<template v-if="scanProgress && scanProgress.total > 0">
10161017
<div class="flex items-center justify-between text-sm">
10171018
<span class="font-medium">Scanning with {{ scanProgress.total }} scanner{{ scanProgress.total !== 1 ? 's' : '' }}...</span>
@@ -1172,7 +1173,7 @@
11721173

11731174
<!-- Action buttons -->
11741175
<div class="flex gap-3">
1175-
<router-link v-if="scanReport.job_id" :to="scanReportPath(scanReport.job_id)" class="btn btn-primary btn-sm">
1176+
<router-link v-if="scanReport.job_id" :to="scanReportPath(scanReport.job_id)" class="btn btn-primary btn-sm" data-test="scan-report-link">
11761177
View Full Report &rarr;
11771178
</router-link>
11781179
</div>
@@ -1248,6 +1249,7 @@ import type { Server, Tool, ToolApproval, SecurityScanReport } from '@/types'
12481249
import api from '@/services/api'
12491250
import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus'
12501251
import { serverDisplayName, scanReportPath } from '@/utils/serverRoute'
1252+
import { isTerminalScanStatus, decideScanReconcile } from '@/utils/scanState'
12511253
import { selectQuarantinedTools } from '@/utils/toolQuarantine'
12521254
import { oauthSignInState } from '@/utils/health'
12531255
import { computeToolDiffSections } from '@/utils/toolDiff'
@@ -2748,7 +2750,7 @@ async function loadScannerNames() {
27482750
}
27492751
}
27502752
2751-
async function loadScanReport(force = false) {
2753+
async function loadScanReport(force = false, skipPolling = false) {
27522754
if (!server.value) return
27532755
// Only load if we have a previous scan (skip check when force-loading after scan completion)
27542756
if (!force && !server.value.security_scan?.last_scan_at && !scanReport.value) return
@@ -2772,11 +2774,24 @@ async function loadScanReport(force = false) {
27722774
}
27732775
if (statusRes.success && statusRes.data) {
27742776
scanStatus.value = statusRes.data
2775-
// If scan is still running (e.g., page reload during scan), resume polling
2776-
if (statusRes.data.status === 'running' || statusRes.data.status === 'pending') {
2777+
const decision = decideScanReconcile(
2778+
{ status: statusRes.data.status, jobId: statusRes.data.id, scanPass: statusRes.data.scan_pass },
2779+
{ scanLoading: scanLoading.value, activeScanJobId: activeScanJobId.value },
2780+
)
2781+
if (decision.resumePolling && !skipPolling) {
2782+
// Scan still running (e.g., page reload during scan) — resume polling.
2783+
// skipPolling=true when called post-finalize: a concurrent Pass-2 job must
2784+
// not re-enable scanLoading and hide the just-completed Pass-1 report.
27772785
activeScanJobId.value = statusRes.data.id
27782786
scanLoading.value = true
27792787
startScanPolling()
2788+
} else if (decision.finalize && scanLoading.value) {
2789+
// MCP-2740: authoritative status is terminal but the spinner is still up
2790+
// (stale flag from a sub-2s scan or one that finished while unmounted).
2791+
// Clear it here — no recursive report reload, we already have the freshest
2792+
// report above.
2793+
clearScanRunState()
2794+
if (decision.isError) scanError.value = statusRes.data.error || 'Scan failed'
27802795
}
27812796
}
27822797
} catch (err) {
@@ -2786,6 +2801,33 @@ async function loadScanReport(force = false) {
27862801
}
27872802
}
27882803
2804+
// Clear the live-scan run state (spinner, polling timer, tracked job id). Pure
2805+
// state reset — does NOT reload the report (callers that need it call separately),
2806+
// which keeps it safe to invoke from inside loadScanReport without recursion.
2807+
function clearScanRunState() {
2808+
stopScanPolling()
2809+
scanLoading.value = false
2810+
activeScanJobId.value = null
2811+
}
2812+
2813+
// Finalize the scan UI from an authoritative terminal (or newer Pass-2) status.
2814+
// Idempotent: safe to call repeatedly. Only emits the success toast when a scan
2815+
// was actually in flight, so reconciling on a fresh tab-open stays silent.
2816+
async function finalizeScan(data: any, decision: { isError: boolean }) {
2817+
const wasLoading = scanLoading.value
2818+
clearScanRunState()
2819+
if (decision.isError) {
2820+
scanError.value = data?.error || 'Scan failed'
2821+
return
2822+
}
2823+
await loadScanReport(true, true)
2824+
await serversStore.fetchServers()
2825+
// server is a computed from the store — no manual reassignment needed.
2826+
if (wasLoading) {
2827+
systemStore.addToast({ type: 'success', title: 'Scan Complete', message: `Security scan for ${server.value?.name} finished.` })
2828+
}
2829+
}
2830+
27892831
function startScanPolling() {
27902832
stopScanPolling()
27912833
scanPollTimer = setInterval(async () => {
@@ -2795,38 +2837,16 @@ function startScanPolling() {
27952837
if (statusResp.success && statusResp.data) {
27962838
// Update scan status for live progress display
27972839
scanStatus.value = statusResp.data
2798-
const jobId = statusResp.data.id
2799-
const status = statusResp.data.status
2800-
2801-
// Only react to the active job (Pass 1). Ignore completed Pass 2 from previous runs.
2802-
if (activeScanJobId.value && jobId !== activeScanJobId.value) {
2803-
// Different job — could be Pass 2 starting after Pass 1 completed.
2804-
if (statusResp.data.scan_pass === 2) {
2805-
// Pass 2 started or completed — Pass 1 is done. Finish polling.
2806-
stopScanPolling()
2807-
scanLoading.value = false
2808-
activeScanJobId.value = null
2809-
await loadScanReport(true)
2810-
await serversStore.fetchServers()
2811-
// server is a computed from the store — no manual reassignment needed.
2812-
systemStore.addToast({ type: 'success', title: 'Scan Complete', message: `Security scan for ${server.value?.name} finished.` })
2813-
}
2814-
return
2815-
}
2816-
2817-
if (status === 'completed' || status === 'complete') {
2818-
stopScanPolling()
2819-
scanLoading.value = false
2820-
activeScanJobId.value = null
2821-
await loadScanReport(true)
2822-
await serversStore.fetchServers()
2823-
// server is a computed from the store — no manual reassignment needed.
2824-
systemStore.addToast({ type: 'success', title: 'Scan Complete', message: `Security scan for ${server.value?.name} finished.` })
2825-
} else if (status === 'failed' || status === 'error') {
2826-
stopScanPolling()
2827-
scanLoading.value = false
2828-
activeScanJobId.value = null
2829-
scanError.value = statusResp.data.error || 'Scan failed'
2840+
const decision = decideScanReconcile(
2841+
{ status: statusResp.data.status, jobId: statusResp.data.id, scanPass: statusResp.data.scan_pass },
2842+
{ scanLoading: scanLoading.value, activeScanJobId: activeScanJobId.value },
2843+
)
2844+
// MCP-2740: finalize the instant the backend reports a terminal status (or a
2845+
// newer Pass-2 job), regardless of activeScanJobId / scan_pass — a sub-2s scan
2846+
// can finalize before the polled id ever matches, which previously left the
2847+
// "Scanning…" spinner stuck and the Report button disabled forever.
2848+
if (decision.finalize) {
2849+
await finalizeScan(statusResp.data, decision)
28302850
}
28312851
}
28322852
} catch {
@@ -2892,6 +2912,17 @@ async function cancelSecurityScan() {
28922912
}
28932913
}
28942914
2915+
// Safety net (MCP-2740): the spinner must never outlive a terminal backend status.
2916+
// If any path sets scanStatus to a terminal status while scanLoading is still true,
2917+
// reconcile here so the UI can't get stuck on "Scanning…" regardless of poll-timer
2918+
// lifecycle or job-id bookkeeping.
2919+
watch(() => scanStatus.value?.status, (status) => {
2920+
if (scanLoading.value && isTerminalScanStatus(status)) {
2921+
clearScanRunState()
2922+
if (!scanReport.value) loadScanReport(true, true)
2923+
}
2924+
})
2925+
28952926
28962927
// Server detail hints
28972928
const serverDetailHints = computed<Hint[]>(() => {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { isTerminalScanStatus, decideScanReconcile } from '@/utils/scanState'
3+
4+
// MCP-2740: A finished security scan stayed stuck on "Scanning… 5/5 complete"
5+
// with the Report button disabled because terminal state was only derived from a
6+
// live poll tick whose job id matched `activeScanJobId`. A fast scan can finalize
7+
// (and be replaced by a Pass-2 job, or just have its id drift) before the first
8+
// 2s tick, so the matched-job branch never fires and `scanLoading` stays true
9+
// forever. The reconciliation below derives terminal state from the authoritative
10+
// backend status on EVERY status fetch, regardless of job-id / scan_pass bookkeeping.
11+
12+
describe('isTerminalScanStatus (MCP-2740)', () => {
13+
it('treats every backend terminal status as terminal', () => {
14+
for (const s of ['completed', 'complete', 'failed', 'error', 'cancelled', 'canceled']) {
15+
expect(isTerminalScanStatus(s)).toBe(true)
16+
}
17+
})
18+
19+
it('treats live/unknown statuses as non-terminal', () => {
20+
for (const s of ['running', 'pending', '', undefined, null]) {
21+
expect(isTerminalScanStatus(s as any)).toBe(false)
22+
}
23+
})
24+
})
25+
26+
describe('decideScanReconcile (MCP-2740)', () => {
27+
const loadingState = { scanLoading: true, activeScanJobId: 'job-A' }
28+
29+
it('finalizes a completed job whose id matches the active job', () => {
30+
const d = decideScanReconcile({ status: 'completed', jobId: 'job-A', scanPass: 1 }, loadingState)
31+
expect(d.finalize).toBe(true)
32+
expect(d.isError).toBe(false)
33+
expect(d.resumePolling).toBe(false)
34+
})
35+
36+
it('finalizes a completed job whose id DIFFERS from the active job (the stuck-UI bug)', () => {
37+
// Previously: jobId !== activeScanJobId && scan_pass !== 2 → early return, never finalized.
38+
const d = decideScanReconcile({ status: 'completed', jobId: 'job-B', scanPass: 1 }, loadingState)
39+
expect(d.finalize).toBe(true)
40+
expect(d.isError).toBe(false)
41+
})
42+
43+
it('finalizes a completed job even when scan_pass is absent', () => {
44+
const d = decideScanReconcile({ status: 'complete', jobId: 'job-B' }, loadingState)
45+
expect(d.finalize).toBe(true)
46+
})
47+
48+
it('finalizes and flags an error for failed/error status', () => {
49+
expect(decideScanReconcile({ status: 'failed', jobId: 'job-A' }, loadingState)).toMatchObject({
50+
finalize: true,
51+
isError: true,
52+
})
53+
expect(decideScanReconcile({ status: 'error', jobId: 'job-A' }, loadingState)).toMatchObject({
54+
finalize: true,
55+
isError: true,
56+
})
57+
})
58+
59+
it('finalizes a cancelled job', () => {
60+
expect(decideScanReconcile({ status: 'cancelled', jobId: 'job-A' }, loadingState).finalize).toBe(true)
61+
})
62+
63+
it('finalizes when a newer Pass-2 job is running (Pass 1 is done)', () => {
64+
const d = decideScanReconcile({ status: 'running', jobId: 'job-B', scanPass: 2 }, loadingState)
65+
expect(d.finalize).toBe(true)
66+
expect(d.isError).toBe(false)
67+
})
68+
69+
it('keeps polling for a still-running active job', () => {
70+
const d = decideScanReconcile({ status: 'running', jobId: 'job-A', scanPass: 1 }, loadingState)
71+
expect(d.finalize).toBe(false)
72+
expect(d.resumePolling).toBe(true)
73+
})
74+
75+
it('keeps polling for a pending job', () => {
76+
const d = decideScanReconcile({ status: 'pending', jobId: 'job-A' }, loadingState)
77+
expect(d.finalize).toBe(false)
78+
expect(d.resumePolling).toBe(true)
79+
})
80+
81+
// Codex P2 (PR #698): after Pass-1 finalizes, loadScanReport(true) is called to
82+
// refresh the report. At that point scanLoading=false and activeScanJobId=null.
83+
// If the backend is already running Pass-2, decideScanReconcile correctly returns
84+
// resumePolling=true — but the caller must pass skipPolling=true so it does NOT
85+
// re-enable scanLoading and hide the just-completed Pass-1 report.
86+
// This test documents that invariant: the cleared state + running Pass-2 → resumePolling.
87+
it('returns resumePolling for a running Pass-2 seen after scan state was cleared (post-finalize)', () => {
88+
const clearedState = { scanLoading: false, activeScanJobId: null }
89+
const d = decideScanReconcile({ status: 'running', jobId: 'job-B', scanPass: 2 }, clearedState)
90+
expect(d.finalize).toBe(false)
91+
expect(d.resumePolling).toBe(true)
92+
// Callers that invoke loadScanReport after clearScanRunState MUST pass
93+
// skipPolling=true to suppress this branch and preserve the Pass-1 report.
94+
})
95+
})
136 KB
Loading
132 KB
Loading
153 KB
Loading
136 KB
Loading

specs/mcp-2740-scan-ui-stuck/verification/report.html

Lines changed: 59 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)