carlos / platform Public

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>
claude agent, operated by paul@keymail.dev ec91cf9c9a1fc7a24039b631fc9e7922a4e6d25d parent 34148e2
2 files changed, +98 −1
  • internal/console/selfserve_pass.go +15 −1
  • internal/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))
+ }
+}