Skip to content

Commit bb7ddcc

Browse files
authored
fix(scanner): Pass 2 dropped by Pass 1 slot race; clock-independent staging deadline test (#1648)
Fixes the two E2E failures on main after #1628 (#1602) that block v0.71.0. ## Root causes 1. **macOS: `TestServiceStartScanDeepOnRunsSourceResolutionAndPass2`** (run 39002404847). Not caused by #1628 (Pass 2 staging/in-process path unchanged; #1628's own main run was green). Real production race: `OnScanCompleted` spawns `startPass2` inside `executeScan`, before the deferred `clearActiveJob` frees the server's engine slot. When Pass 2 reaches `StartScan` first it is rejected with "scan already in progress", logged at Warn and dropped: no Pass 2 job is ever saved (test times out after 10s) and the resolved source dir leaked. Slow runners widen the window. 2. **Windows: `TestStagedSource_ScanDeadlineCoversStaging`**. A 1ns `context.WithTimeout` is not observably expired on the coarse Windows clock, so staging of a tiny dir finished before the ctx check. ## Fix - `ErrScanInProgress` sentinel; `startPass2` retries (bounded 10s) while the blocking job is a Pass-1 job, releases the resolved source and saves a failed Pass 2 job if it still cannot start. - Staging test now uses a 20ms timeout, plus a non-skipped source file and a stall in the `stagingAfterReadBatch` seam past the deadline, so the following ctx check deterministically fails. Still proves staging runs under the scan deadline. - New regression test `TestStartPass2WaitsForPass1SlotRelease` (fails before: Pass 2 saved as failed with "scan already in progress"). ## Verification `go test -race ./internal/security/...` green, `-count=30` on the touched tests, `GOOS=windows go vet`, both golangci-lint runs (bare and `--build-tags server`) 0 issues. The macOS flake did not reproduce locally under CPU load; diagnosis is from code plus the deterministic regression test.
1 parent 3888c77 commit bb7ddcc

6 files changed

Lines changed: 121 additions & 11 deletions

File tree

‎internal/security/scanner/engine.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"io"
910
"os"
@@ -314,6 +315,10 @@ func (n *NoopCallback) OnScannerFailed(_ *ScanJob, _ string, _ error) {
314315
func (n *NoopCallback) OnScanCompleted(_ *ScanJob, _ []*ScanReport) {}
315316
func (n *NoopCallback) OnScanFailed(_ *ScanJob, _ error) {}
316317

318+
// ErrScanInProgress is returned (wrapped) by StartScan when the server already
319+
// has a registered scan job.
320+
var ErrScanInProgress = errors.New("scan already in progress")
321+
317322
// StartScan begins a scan of the specified server
318323
// Returns the scan job immediately; scanning runs in the background
319324
func (e *Engine) StartScan(ctx context.Context, req ScanRequest, callback ScanCallback) (*ScanJob, error) {
@@ -333,7 +338,7 @@ func (e *Engine) StartScan(ctx context.Context, req ScanRequest, callback ScanCa
333338
// Snapshot: the running scan keeps writing to the live record.
334339
snapshot := existing.clone()
335340
e.mu.Unlock()
336-
return snapshot, fmt.Errorf("scan already in progress for server %s (job %s)", req.ServerName, snapshot.ID)
341+
return snapshot, fmt.Errorf("%w for server %s (job %s)", ErrScanInProgress, req.ServerName, snapshot.ID)
337342
}
338343
if check := e.registrationCheck; check != nil && !check(req.ServerName, req.Incarnation) {
339344
e.mu.Unlock()

‎internal/security/scanner/export_ingested_contracts_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ func TestRT06_Pass2_LiveExportError_RunsOnlyMetadataIndependentScanners(t *testi
342342
source := staleSourceDir(t)
343343
info := &ServerInfo{Name: "toctou-p2", Protocol: "stdio", Command: "node", Args: []string{"server.js"}, WorkingDir: source}
344344
svc, store := newR3Service(t, &liveFailProvider{info: info, liveErr: errors.New("server not found: toctou-p2")})
345-
svc.startPass2("toctou-p2", info, 1)
345+
svc.startPass2("toctou-p2", info, 1, "")
346346

347347
require.Eventually(t, func() bool {
348348
jobs, err := store.ListScanJobs("toctou-p2")
@@ -436,7 +436,7 @@ func TestRT06_Pass2_LiveExportError_TrivyRunsMetadataScannerRefused(t *testing.T
436436
return "", "", 0, nil
437437
}
438438

439-
svc.startPass2("toctou-p2", info, 1)
439+
svc.startPass2("toctou-p2", info, 1, "")
440440
require.Eventually(t, func() bool {
441441
jobs, err := store.ListScanJobs("toctou-p2")
442442
return err == nil && len(jobs) == 1 && jobs[0].Status != ScanJobStatusRunning && jobs[0].Status != ScanJobStatusPending

‎internal/security/scanner/late_scan_r1_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func TestPass2_R1_UsesOriginatingIncarnation(t *testing.T) {
3131
svc.saveFailedPass2Job("acme", "source resolution failed", 1) // A's job was incarnation 1
3232
assert.Empty(t, base.jobs, "A's Pass 2 placeholder is not stored under B")
3333

34-
svc.startPass2("acme", &ServerInfo{Name: "acme", Protocol: "stdio", Command: "true"}, 1)
34+
svc.startPass2("acme", &ServerInfo{Name: "acme", Protocol: "stdio", Command: "true"}, 1, "")
3535
assert.Nil(t, svc.engine.GetActiveJob("acme"), "no Pass 2 runs for a replaced incarnation")
3636
assert.Empty(t, base.jobs)
3737
assert.Empty(t, base.reports)

‎internal/security/scanner/service.go‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,7 +1154,7 @@ func (a *scanCallbackAdapter) OnScanCompleted(job *ScanJob, reports []*ScanRepor
11541154
// The ORIGINATING job's incarnation (review round 1): a late
11551155
// Pass 1 of a removed server must not bind Pass 2 to a
11561156
// same-name replacement.
1157-
go a.service.startPass2(job.ServerName, a.serverInfo, job.Incarnation)
1157+
go a.service.startPass2(job.ServerName, a.serverInfo, job.Incarnation, job.ID)
11581158
}
11591159
}
11601160
}
@@ -1453,7 +1453,9 @@ func (s *Service) StartScan(ctx context.Context, serverName string, dryRun bool,
14531453
// inc is the incarnation of the Pass 1 job that scheduled it: Pass 2 and its
14541454
// failed-source placeholder bind to it, and Pass 2 does not start at all once
14551455
// that incarnation is no longer current (Spec 116 FR-014a, review round 1).
1456-
func (s *Service) startPass2(serverName string, serverInfo *ServerInfo, inc int64) {
1456+
// pass1JobID names the Pass-1 job that scheduled this audit ("" when none):
1457+
// only that job's not-yet-cleared slot is waited on.
1458+
func (s *Service) startPass2(serverName string, serverInfo *ServerInfo, inc int64, pass1JobID string) {
14571459
if !s.incarnationIsCurrent(serverName, inc) {
14581460
s.logger.Info("Pass 2 skipped: the server was removed or replaced since Pass 1 started",
14591461
zap.String("server", serverName), zap.Int64("incarnation", inc))
@@ -1554,14 +1556,47 @@ func (s *Service) startPass2(serverName string, serverInfo *ServerInfo, inc int6
15541556
scanPass: ScanPassSupplyChainAudit,
15551557
}
15561558

1557-
_, err := s.engine.StartScan(ctx, req, callback)
1559+
// Pass 1's OnScanCompleted (which spawns this goroutine) runs inside
1560+
// executeScan BEFORE its deferred clearActiveJob releases the server's
1561+
// slot, so a quick Pass 2 can find Pass 1's own job still registered and
1562+
// be rejected with ErrScanInProgress, silently dropping the audit. Wait
1563+
// (bounded) for that slot to free instead.
1564+
_, err := s.startScanAfterPass1Slot(ctx, req, callback, pass1JobID)
15581565
if err != nil {
15591566
s.logger.Warn("Failed to start Pass 2 scan",
15601567
zap.String("server", serverName),
15611568
zap.Error(err),
15621569
)
1563-
// If the engine rejected (e.g., scan already in progress for same server),
1564-
// just log and move on. Pass 2 is best-effort.
1570+
// The callback only owns the cleanup for an ACCEPTED scan.
1571+
if resolvedCleanup != nil {
1572+
resolvedCleanup()
1573+
}
1574+
s.saveFailedPass2Job(serverName, "supply chain audit could not start: "+err.Error(), inc)
1575+
}
1576+
}
1577+
1578+
// pass2SlotWait bounds how long Pass 2 waits for Pass 1's job to leave the
1579+
// engine's active set (a var so tests can shrink it).
1580+
var pass2SlotWait = 10 * time.Second
1581+
1582+
// startScanAfterPass1Slot starts req, retrying while the only thing in the way
1583+
// is the originating Pass-1 job (pass1JobID), which has finished its work but
1584+
// not yet been cleared. Any other blocker (a user-triggered scan that started
1585+
// in between, another Pass 2) is not waited on.
1586+
func (s *Service) startScanAfterPass1Slot(ctx context.Context, req ScanRequest, cb ScanCallback, pass1JobID string) (*ScanJob, error) {
1587+
deadline := time.Now().Add(pass2SlotWait)
1588+
for {
1589+
job, err := s.engine.StartScan(ctx, req, cb)
1590+
if err == nil || !errors.Is(err, ErrScanInProgress) {
1591+
return job, err
1592+
}
1593+
if blocker := s.engine.GetActiveJob(req.ServerName); pass1JobID == "" || (blocker != nil && blocker.ID != pass1JobID) {
1594+
return job, err // not our Pass 1: do not wait on it
1595+
}
1596+
if !time.Now().Before(deadline) {
1597+
return job, err
1598+
}
1599+
time.Sleep(10 * time.Millisecond)
15651600
}
15661601
}
15671602

‎internal/security/scanner/service_test.go‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2963,3 +2963,65 @@ func TestGetScanReportByJobIDVerdictMatchesSummary(t *testing.T) {
29632963
t.Errorf("raw report Summary.Dangerous must keep the threat-level count, got %d", report.Summary.Dangerous)
29642964
}
29652965
}
2966+
2967+
// TestStartPass2WaitsForPass1SlotRelease: Pass 1's OnScanCompleted spawns Pass
2968+
// 2 inside executeScan, before the deferred clearActiveJob frees the server's
2969+
// engine slot. Pass 2 used to be rejected with "scan already in progress" and
2970+
// silently dropped (no job ever saved) whenever it won that race. It must wait
2971+
// for the slot instead.
2972+
func TestStartPass2WaitsForPass1SlotRelease(t *testing.T) {
2973+
dir := t.TempDir()
2974+
logger := zap.NewNop()
2975+
store := newMockStorage()
2976+
svc := NewService(store, NewRegistry(dir, logger), NewDockerRunner(logger), dir, logger)
2977+
info := &ServerInfo{Name: "srv-slot", Protocol: "stdio", Command: "node", Args: []string{"server.js"}, WorkingDir: t.TempDir()}
2978+
svc.SetServerInfoProvider(&scanSpyProvider{info: info})
2979+
svc.SetDeepScan(true, nil)
2980+
2981+
// Simulate Pass 1 completed but not yet cleared.
2982+
pass1 := &ScanJob{ID: "pass1", ServerName: "srv-slot", Status: ScanJobStatusCompleted, ScanPass: ScanPassSecurityScan}
2983+
svc.engine.mu.Lock()
2984+
svc.engine.activeScans["srv-slot"] = pass1
2985+
svc.engine.mu.Unlock()
2986+
go func() {
2987+
time.Sleep(300 * time.Millisecond)
2988+
svc.engine.clearActiveJob("srv-slot", pass1)
2989+
}()
2990+
2991+
svc.startPass2("srv-slot", info, 0, "pass1")
2992+
waitForPass2Saved(t, store, "srv-slot")
2993+
jobs, _ := store.ListScanJobs("srv-slot")
2994+
for _, j := range jobs {
2995+
if j.ScanPass == ScanPassSupplyChainAudit && j.Status == ScanJobStatusFailed {
2996+
t.Fatalf("Pass 2 must run once the slot frees, got failed job: %s", j.Error)
2997+
}
2998+
}
2999+
waitForScanIdle(t, svc, "srv-slot")
3000+
}
3001+
3002+
// TestStartPass2DoesNotWaitOnUnrelatedPass1: when the slot is held by a
3003+
// different Pass-1 job (e.g. a user-triggered rescan that started after the
3004+
// originating Pass 1 was cleared), Pass 2 must not wait out pass2SlotWait on
3005+
// it; it records a failed audit at once.
3006+
func TestStartPass2DoesNotWaitOnUnrelatedPass1(t *testing.T) {
3007+
dir := t.TempDir()
3008+
logger := zap.NewNop()
3009+
store := newMockStorage()
3010+
svc := NewService(store, NewRegistry(dir, logger), NewDockerRunner(logger), dir, logger)
3011+
info := &ServerInfo{Name: "srv-other", Protocol: "stdio", Command: "node", Args: []string{"server.js"}, WorkingDir: t.TempDir()}
3012+
svc.SetServerInfoProvider(&scanSpyProvider{info: info})
3013+
svc.SetDeepScan(true, nil)
3014+
3015+
other := &ScanJob{ID: "user-rescan", ServerName: "srv-other", Status: ScanJobStatusRunning, ScanPass: ScanPassSecurityScan}
3016+
svc.engine.mu.Lock()
3017+
svc.engine.activeScans["srv-other"] = other
3018+
svc.engine.mu.Unlock()
3019+
t.Cleanup(func() { svc.engine.clearActiveJob("srv-other", other) })
3020+
3021+
start := time.Now()
3022+
svc.startPass2("srv-other", info, 0, "pass1")
3023+
if elapsed := time.Since(start); elapsed > 2*time.Second {
3024+
t.Fatalf("Pass 2 waited %s on an unrelated Pass-1 job", elapsed)
3025+
}
3026+
waitForPass2Saved(t, store, "srv-other")
3027+
}

‎internal/security/scanner/source_staging_test.go‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ func TestStagedSource_CopyCancelledByDeadline(t *testing.T) {
301301
// when the caller passed context.Background().
302302
func TestStagedSource_ScanDeadlineCoversStaging(t *testing.T) {
303303
source := writeToolsJSON(t, []map[string]interface{}{rt06Tool(rt06Poisoned)})
304+
require.NoError(t, os.WriteFile(filepath.Join(source, "main.js"), []byte("x"), 0o644)) // a non-skipped entry for staging to check ctx on
304305
snapshot, err := os.ReadFile(filepath.Join(source, "tools.json"))
305306
require.NoError(t, err)
306307
logger := zap.NewNop()
@@ -312,9 +313,16 @@ func TestStagedSource_ScanDeadlineCoversStaging(t *testing.T) {
312313
return "", "", 0, nil
313314
}
314315
req := ScanRequest{ServerName: "dl", SourceDir: source, ToolDefinitions: snapshot}
315-
plugin := &ScannerPlugin{ID: "mcp-scan", DockerImage: "example/mcp-scan:test", Command: []string{"scan"}, Timeout: "1ns"}
316+
// A 1ns timeout is not reliably expired on coarse-clock platforms
317+
// (Windows), so staging could finish before the deadline was observable.
318+
// Use a short timeout and stall inside staging, after the first directory
319+
// batch, until the deadline has certainly passed: the next ctx check in
320+
// staging must then fail. This proves staging runs under the scan deadline.
321+
t.Cleanup(func() { stagingAfterReadBatch = nil })
322+
stagingAfterReadBatch = func(int) { time.Sleep(300 * time.Millisecond) }
323+
plugin := &ScannerPlugin{ID: "mcp-scan", DockerImage: "example/mcp-scan:test", Command: []string{"scan"}, Timeout: "20ms"}
316324
_, _, err = engine.runSingleScanner(context.Background(), plugin, req)
317-
require.Error(t, err, "an already-expired scan deadline stops staging")
325+
require.Error(t, err, "an expired scan deadline stops staging")
318326
assert.ErrorIs(t, err, context.DeadlineExceeded)
319327
}
320328

0 commit comments

Comments
 (0)