carlos / platform Public

console: restore announces itself, and a bump outlives its request

Two review findings on the serial, the deploy signal's other half.

Restore was the one box-consumed door that wrote and said nothing.
Pulling an app's trash marker re-enables instance minting, and minting
from records is leg 1 of the fast pass's reconcile — so a restore
without a bump leaves the app un-mintable until the hourly floor, the
exact wait the delete side already announces its way out of.

And bumpSerial inherited the REQUEST's context. By the time a bump
runs, the write it announces has landed and cannot be taken back, so a
client that hangs up in that window — a killed CI runner, a closed
laptop, a proxy timeout — silently cancelled the announcement for a
change that is permanently in the bucket; the fleet then waits out the
hour for a deploy that already happened. context.WithoutCancel detaches
it. The 2s clamp is unchanged: detached from cancellation, not time.

Both are pinned. The cancellation test needs a store that HONOURS ctx
(cancelAwareStore) — the DirStore is local disk and ignores it, so the
test would otherwise have passed for the wrong reason. Verified by
reverting each fix in turn: restore = 0 want 1, canceled bump = 0 want 1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e8dc3d371fb56e9dbcbcac160c89236d62e6fd03 parent ff327b6
3 files changed, +75 −1
  • internal/console/ownership.go +6 −0
  • internal/console/serialbump.go +11 −1
  • internal/console/serialbump_test.go +58 −0
diff --git a/internal/console/ownership.go b/internal/console/ownership.go
index e7ab12b33..6e6055b69 100644
--- a/internal/console/ownership.go
+++ b/internal/console/ownership.go
@@ -243,6 +243,12 @@ func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) {
return
}
s.audit(acct, app, addr, "restore", "restored "+app+" from the trash")
+ // Box-consumed (carlos-deploy §3): removing the trash marker re-enables
+ // instance minting for this app, and minting from records is leg 1 of the
+ // fast pass's reconcile. Without the bump a restore is invisible to the
+ // fleet until the hourly floor comes round — the same wait the delete side
+ // already announces its way out of.
+ s.bumpSerial(r.Context())
// The anti-ghost rule, said where the person can read it: restoring brings
// back releases and channels and no routes at all (spec §3), so an app
// restored and left alone serves nothing and nobody can tell why.
diff --git a/internal/console/serialbump.go b/internal/console/serialbump.go
index 9f3facea1..c6be80555 100644
--- a/internal/console/serialbump.go
+++ b/internal/console/serialbump.go
@@ -18,11 +18,21 @@ import (
// serial carries no authority at all (it is the one unsigned object in the
// bucket); the worst a spurious bump can do is make a box run its ordinary
// signature-verified pass set a little early.
+//
+// context.WithoutCancel is the whole point of the ctx handling here: the
+// caller's context is the REQUEST's, and by the time a bump runs the write it
+// announces has already landed and is irreversible. A client that hangs up in
+// that window (a CI runner killed mid-promote, a closed laptop, a proxy
+// timeout) would otherwise cancel the announcement for a change that is
+// permanently in the bucket — the fleet then waits out the hourly floor for a
+// deploy that already happened, with nothing in any log to say why. The 2s
+// clamp still applies, measured from here: detached from cancellation, not
+// from time.
func (s *Server) bumpSerial(ctx context.Context) {
if s.cfg.Releases == nil {
return // no bucket on this console: nothing to announce into
}
- ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
if err := release.BumpSerial(ctx, s.cfg.Releases, time.Now()); err != nil {
log.Printf("console: serial bump: %v (boxes fall back to the hourly pass)", err)
diff --git a/internal/console/serialbump_test.go b/internal/console/serialbump_test.go
index e05c4272c..bbfe715fb 100644
--- a/internal/console/serialbump_test.go
+++ b/internal/console/serialbump_test.go
@@ -365,6 +365,64 @@ func TestWebProductionBumpsSerial(t *testing.T) {
}
}
+// TestWebRestoreBumpsSerial: restore is a box-consumed write like every other
+// door here — pulling the trash marker re-enables instance minting for the
+// app, and minting from records is leg 1 of the fast pass's reconcile. An
+// un-announced restore leaves the app un-mintable until the hourly floor,
+// which is exactly the wait the delete side already announces its way out of.
+func TestWebRestoreBumpsSerial(t *testing.T) {
+ fx, c, csrf := trashPageFixture(t)
+
+ before := serial(t, fx)
+ status, _, body := postForm(t, c, fx.u("/trash/restore"),
+ url.Values{"csrf": {csrf}, "app": {"kass"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("restore = %d, want 303:\n%s", status, body)
+ }
+ if got := serial(t, fx); got != before+1 {
+ t.Fatalf("serial after a restore = %d, want %d", got, before+1)
+ }
+}
+
+// cancelAwareStore is a bucket that honours context cancellation, the way the
+// AWS SDK does and the DirStore (local disk, no ctx checks at all) does not —
+// without it, a canceled-context test here would pass for the wrong reason.
+type cancelAwareStore struct{ release.Store }
+
+func (c cancelAwareStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, "", err
+ }
+ return c.Store.Get(ctx, key)
+}
+
+func (c cancelAwareStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if err := ctx.Err(); err != nil {
+ return "", err
+ }
+ return c.Store.Put(ctx, key, body, ifMatch)
+}
+
+// TestBumpSerialOutlivesTheRequest: the announcement is detached from the
+// request context (context.WithoutCancel). By the time a bump runs, the write
+// it announces has already landed and cannot be taken back — so a client that
+// hangs up in that window (a killed CI runner, a closed laptop, a proxy
+// timeout) must not cancel the announcement for a change that is permanently
+// in the bucket. Inheriting the cancellation loses it silently: the fleet
+// waits out the hourly floor for a deploy that already happened.
+func TestBumpSerialOutlivesTheRequest(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.Releases = cancelAwareStore{fx.rel}
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // the client is already gone
+ fx.srv.bumpSerial(ctx)
+
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after a bump on a canceled request context = %d, want 1", got)
+ }
+}
+
// TestBumpSerialSurvivesABrokenStore: the whole contract in one test — a bump
// against a store that cannot be written returns normally and logs. Every call
// site depends on this: a user-facing write must never fail, or stall, because