self-serve pass: the start scan and the evidence read are bounded (final review F12)
SelfServeEvidence bounds its reservations read by selfServeEvidenceTimeout (30 s), so neither NewServer's recovery scan nor cmd/console's paging-arm read can hold the console's start on a stalled bucket; the start scan runs under the same bound. A read that times out is recovery mode on (fail closed), logged, and the pass re-evaluates each tick. Witness: TestStartupRecoveryScanIsBounded (NewServer over a store that waits for cancellation, switch off).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+98
−1
internal/console/selfserve_pass.go+15 −1internal/console/selfserve_startup_test.go+83 −0
diff --git a/internal/console/selfserve_pass.go b/internal/console/selfserve_pass.go| index 8a345f78c..0bf260f08 100644 |
| --- a/internal/console/selfserve_pass.go |
| +++ b/internal/console/selfserve_pass.go |
| @@ -15,11 +15,19 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// selfServeEvidenceTimeout bounds one evidence read (SelfServeEvidence): |
| +// the console's start, the paging arm and every tick's re-evaluation (final |
| +// review F12). A var so a test can shorten it. |
| +var selfServeEvidenceTimeout = 30 * time.Second |
| + |
| // SelfServeEvidence reports whether anything puts a console in recovery |
| // mode: an open self-serve row, a pending release, owed effects or open |
| // record, an undelivered event (the store), or any reservation in |
| // active.json (rel; nil reads no reservations). An error means it could |
| -// not tell; callers fail closed and treat it as found. |
| +// not tell; callers fail closed and treat it as found. The reservations |
| +// read is bounded by selfServeEvidenceTimeout, so a stalled bucket cannot |
| +// hold the console's start or its paging arm (final review F12): a read |
| +// that times out is an error like any other. |
| func SelfServeEvidence(ctx context.Context, st *Store, rel release.Store) (bool, error) { |
| pending, err := st.SelfServeRecoveryPending() |
| if err != nil { |
| @@ -28,6 +36,8 @@ func SelfServeEvidence(ctx context.Context, st *Store, rel release.Store) (bool, |
| if pending || rel == nil { |
| return pending, nil |
| } |
| + ctx, cancel := context.WithTimeout(ctx, selfServeEvidenceTimeout) |
| + defer cancel() |
| raw, _, err := rel.Get(ctx, moves.OfflineActiveKey) |
| if errors.Is(err, os.ErrNotExist) { |
| return false, nil |
| @@ -46,10 +56,14 @@ func SelfServeEvidence(ctx context.Context, st *Store, rel release.Store) (bool, |
| // with anything the pass still owes, or when it cannot tell (fail closed: |
| // the pass runs and retries the reads each tick). A host-claim console has |
| // no self-serve moves (the switch needs claims off), so it scans nothing. |
| +// The scan is bounded (selfServeEvidenceTimeout): a read that times out is |
| +// recovery mode on, logged, and the pass re-evaluates each tick. |
| func (s *Server) selfServeScanAtStart(ctx context.Context) { |
| if s.cfg.HostClaim { |
| return |
| } |
| + ctx, cancel := context.WithTimeout(ctx, selfServeEvidenceTimeout) |
| + defer cancel() |
| found, err := SelfServeEvidence(ctx, s.store, s.cfg.Releases) |
| if err != nil { |
| log.Printf("console: self-serve moves: reading at start: %v (recovery mode on; the pass retries each tick)", err) |
diff --git a/internal/console/selfserve_startup_test.go b/internal/console/selfserve_startup_test.go| new file mode 100644 |
| index 000000000..d1a9e92c3 |
| --- /dev/null |
| +++ b/internal/console/selfserve_startup_test.go |
| @@ -0,0 +1,83 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// stallStore waits for its context's cancellation on every read of key: a |
| +// bucket that stalls. |
| +type stallStore struct { |
| + release.Store |
| + key string |
| +} |
| + |
| +func (s *stallStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == s.key { |
| + <-ctx.Done() |
| + return nil, "", ctx.Err() |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +// TestStartupRecoveryScanIsBounded (final review F12): a reservations read |
| +// that stalls cannot hold the console's start. NewServer's recovery scan |
| +// and the paging-arm evidence read (SelfServeEvidence) give up at their |
| +// deadline; the console then starts in recovery mode (fail closed) and says |
| +// so, and the pass re-evaluates each tick. With the switch off, as a |
| +// claims-off console that never moves anything starts. |
| +func TestStartupRecoveryScanIsBounded(t *testing.T) { |
| + defer func(d time.Duration) { selfServeEvidenceTimeout = d }(selfServeEvidenceTimeout) |
| + selfServeEvidenceTimeout = 200 * time.Millisecond |
| + |
| + regPath, relStore, priv, pub := buildFixtures(t) |
| + st, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + rel := &stallStore{Store: relStore, key: moves.OfflineActiveKey} |
| + var logs strings.Builder |
| + restore := captureLogTo(&logs) |
| + type built struct { |
| + s *Server |
| + err error |
| + } |
| + done := make(chan built, 1) |
| + go func() { |
| + s, err := NewServer(Config{Origin: "http://127.0.0.1", Keymail: "http://keymail.invalid", Registry: regPath, Releases: rel, |
| + ReleasePub: pub, Key: priv, AppScheme: "http"}, st) |
| + done <- built{s, err} |
| + }() |
| + var b built |
| + select { |
| + case b = <-done: |
| + case <-time.After(10 * time.Second): |
| + restore() |
| + t.Fatal("NewServer did not return while the reservations read stalled") |
| + } |
| + restore() |
| + if b.err != nil { |
| + t.Fatal(b.err) |
| + } |
| + if !b.s.selfServeMode() || b.s.selfServeAdmits() { |
| + t.Fatalf("mode %v admits %v after a stalled start read; want recovery mode without admission", b.s.selfServeMode(), b.s.selfServeAdmits()) |
| + } |
| + if !strings.Contains(logs.String(), "recovery mode on") { |
| + t.Fatalf("the timed-out start read was not logged: %q", logs.String()) |
| + } |
| + |
| + // The paging-arm read (cmd/console's armMovesPaging) is the same bound |
| + // read: it answers found with the error, and arms. |
| + start := time.Now() |
| + found, err := SelfServeEvidence(context.Background(), st, rel) |
| + if !found || err == nil || time.Since(start) > 5*time.Second { |
| + t.Fatalf("the evidence read over a stalled bucket = %v %v after %v; want found, an error, bounded", found, err, time.Since(start)) |
| + } |
| +} |