hub: a probe writes nothing at all, kind included
S2-10 (P2): memberStandingGone honoured end=false only when standing was absent; with standing present it still wrote the kind, so a member taken off the team while holding a live guest grant was demoted by a probe — and, if admitted, that demotion broadcast a fresh roster to the whole room. A probe changes nothing (§15.3): the transition is left to the next mutating request or the tick. S2-14 (P3): the tick-deadline test asserted that a constant equalled itself, which left the relationship it names free to change with the test green. The scheduling path is now tickLoop(ctx, every) with the pass behind a nil-in-production seam, and the test runs the real loop in milliseconds and records the deadline it hands out.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
4 files changed,
+125
−14
internal/meet/app.go+24 −3internal/meet/hub/admission.go+12 −6internal/meet/hub/relay_test.go+60 −0internal/meet/tick_test.go+29 −5
diff --git a/internal/meet/app.go b/internal/meet/app.go| index ffb2daf..7c9ae3e 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -40,6 +40,11 @@ type app struct { |
| // (spec §4.4): thirty a second, burst sixty. |
| signalLimiter *rateLimiter |
| + // tickPass is the housekeeping pass tickLoop runs, nil in production |
| + // and everywhere but the one test that records what deadline the |
| + // scheduling path hands it (see tickLoop). |
| + tickPass func(ctx context.Context, within time.Duration) |
| + |
| // done closes when the context App was given is cancelled — a |
| // SIGTERM, in the deployed shape. Live streams watch it and return, |
| // because http.Server.Shutdown waits for handlers without |
| @@ -231,15 +236,31 @@ const tickEvery = 15 * time.Second |
| // tickForever is started by App and not by NewForTest, for the reason |
| // sweepForever is: a ticker per test app is a goroutine per test that |
| // outlives it. Tests drive it with TestHandle.TickForTest. |
| -func (a *app) tickForever(ctx context.Context) { |
| - t := time.NewTicker(tickEvery) |
| +func (a *app) tickForever(ctx context.Context) { a.tickLoop(ctx, tickEvery) } |
| + |
| +// tickLoop is the scheduling path, with the interval as a parameter so |
| +// the one property that matters can be gated in milliseconds rather than |
| +// in fifteen-second slots: the deadline each pass is given is the |
| +// interval between passes, so a pass can never overrun the slot the next |
| +// one needs. `every` goes to the ticker and to the pass, in one place, |
| +// and there is no second constant to drift from the first. |
| +// |
| +// tickPass is nil everywhere but in that test, which substitutes a |
| +// recorder for it; the alternative — asserting a constant equals itself |
| +// — left the relationship unprotected (Codex S2-14). |
| +func (a *app) tickLoop(ctx context.Context, every time.Duration) { |
| + pass := a.tickPass |
| + if pass == nil { |
| + pass = a.tickOnce |
| + } |
| + t := time.NewTicker(every) |
| defer t.Stop() |
| for { |
| select { |
| case <-ctx.Done(): |
| return |
| case <-t.C: |
| - a.tickOnce(ctx, tickEvery) |
| + pass(ctx, every) |
| } |
| } |
| } |
diff --git a/internal/meet/hub/admission.go b/internal/meet/hub/admission.go| index 160b281..9b96e41 100644 |
| --- a/internal/meet/hub/admission.go |
| +++ b/internal/meet/hub/admission.go |
| @@ -145,12 +145,15 @@ func (h *Hub) standingLostLocked(r *room, p *Participation) bool { |
| // makes the participation the authority once someone is admitted, and a |
| // waiting guest is the tick's to expire. |
| // |
| -// end says whether this call may write. The mutating callers (Relay, |
| -// Reset, Resume) end a member who has lost standing here and now, with |
| -// lost{standing} and the departure the room is owed; the probe |
| -// (CanResume) passes false, answers, and leaves the ending to the next |
| -// mutating request or to the tick — a probe is defined as changing |
| -// nothing (§15.3). |
| +// end says whether this call may write **at all**. The mutating callers |
| +// (Relay, Reset, Resume) end a member who has lost standing here and |
| +// now, with lost{standing} and the departure the room is owed, and |
| +// record a kind that has changed; the probe (CanResume) passes false and |
| +// writes nothing whatever the answer was — not the ending, and not the |
| +// kind either, because recording a member's demotion to guest would |
| +// broadcast a fresh roster to every admitted peer, which is a room-wide |
| +// event caused by a request that is defined as changing nothing (§15.3, |
| +// Codex S2-10). The next mutating request, or the tick, applies it. |
| // |
| // The standing read happens with the lock down, like every other one |
| // (StandingFor is a database read), so the participation is re-found |
| @@ -189,6 +192,9 @@ func (h *Hub) memberStandingGone(ctx context.Context, sqid, session string, peer |
| } |
| return h.standingLostLocked(r, p), nil |
| } |
| + if !end { |
| + return false, nil |
| + } |
| h.setKindLocked(r, p, kind) |
| return false, nil |
| } |
diff --git a/internal/meet/hub/relay_test.go b/internal/meet/hub/relay_test.go| index bddae33..71266de 100644 |
| --- a/internal/meet/hub/relay_test.go |
| +++ b/internal/meet/hub/relay_test.go |
| @@ -4,6 +4,8 @@ import ( |
| "context" |
| "encoding/json" |
| "errors" |
| + "io" |
| + "log/slog" |
| "testing" |
| "time" |
| ) |
| @@ -333,6 +335,64 @@ func TestATombstoneIsAnsweredOnlyThroughItsOwnRoomAndSession(t *testing.T) { |
| } |
| } |
| +// Spec §15.3: a probe changes nothing. Not the participation, and not |
| +// the kind either — recording a member's demotion to guest would send a |
| +// fresh roster to every admitted peer in the room, which is a room-wide |
| +// event caused by a request whose whole contract is that it has no |
| +// effects (Codex S2-10). The transition is real, and is applied by the |
| +// next thing this member actually does. |
| +func TestAProbeNeverWritesAKindNorBroadcastsARoster(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + // Standing this test can move: a member taken off the team while |
| + // holding a live guest grant is still someone — ok stays true — and |
| + // their kind becomes guest. That is the branch a probe wrote through. |
| + kind := KindMember |
| + h := New(Config{ |
| + Now: func() time.Time { return now }, |
| + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| + StandingFor: func(ctx context.Context, room string, personID int64) (Kind, bool, error) { |
| + return kind, true, nil |
| + }, |
| + }) |
| + a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| + h.Join(testCtx, "r1", "sa", a.Peer) |
| + b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| + h.Join(testCtx, "r1", "sb", b.Peer) |
| + drain(a.Sub) |
| + drain(b.Sub) |
| + |
| + kind = KindGuest // off the team, grant still live |
| + |
| + if re := h.CanResume(testCtx, "r1", "sa", a.Peer); re != nil { |
| + t.Fatalf("the probe refused a participation that is still there: %+v", re) |
| + } |
| + p, ok := h.Find("r1", "sa", a.Peer) |
| + if !ok || p.Kind != KindMember { |
| + t.Fatalf("the probe wrote the kind: %v", p.Kind) |
| + } |
| + if ev := drain(a.Sub); len(ev) != 0 { |
| + t.Fatalf("the probe sent its own stream %v", types(ev)) |
| + } |
| + if ev := drain(b.Sub); len(ev) != 0 { |
| + t.Fatalf("the probe sent the room %v", types(ev)) |
| + } |
| + |
| + // The next mutating request applies it, and owes the room the roster |
| + // a kind change earns (§2.4). |
| + if err := h.Relay(testCtx, "r1", "sa", a.Peer, Message{Type: TypeOffer, To: b.Peer, Seq: 1, Gen: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, _ := h.Find("r1", "sa", a.Peer); p.Kind != KindGuest { |
| + t.Fatalf("kind after a mutating request: %v, want guest", p.Kind) |
| + } |
| + if ev := types(drain(a.Sub)); len(ev) != 1 || ev[0] != TypeRoster { |
| + t.Fatalf("the demoted member got %v, want one roster", ev) |
| + } |
| + if ev := types(drain(b.Sub)); len(ev) != 2 || ev[0] != TypeRoster || ev[1] != TypeOffer { |
| + t.Fatalf("the room got %v, want the roster then the offer", ev) |
| + } |
| +} |
| + |
| func TestResumeAfterWindowIsA404WithTheSameBoot(t *testing.T) { |
| h, now, a, _ := twoAdmitted(t) |
| h.Detach("r1", a.Peer, nil) |
diff --git a/internal/meet/tick_test.go b/internal/meet/tick_test.go| index 77da4ba..ab9c334 100644 |
| --- a/internal/meet/tick_test.go |
| +++ b/internal/meet/tick_test.go |
| @@ -74,11 +74,35 @@ func TestATickPassEndsOnItsOwnDeadline(t *testing.T) { |
| } |
| } |
| -// The deadline is not decoration: tickForever passes the interval, so a |
| -// pass can never overrun the slot the next one needs. Asserted here |
| -// because the constant and the call are in different places and a |
| -// future edit could quietly separate them. |
| -func TestTheTickDeadlineIsTheTickInterval(t *testing.T) { |
| +// The deadline is not decoration: the scheduling path gives each pass |
| +// the interval between passes, so a pass can never overrun the slot the |
| +// next one needs. This used to assert that a constant equalled itself, |
| +// which left the relationship free to be changed with the test green |
| +// (Codex S2-14) — so what runs here is the loop itself, in milliseconds, |
| +// with a recorder in the pass's place. |
| +func TestTheSchedulingPathGivesEachPassTheIntervalAsItsDeadline(t *testing.T) { |
| + const every = 20 * time.Millisecond |
| + got := make(chan time.Duration, 1) |
| + a := &app{tickPass: func(ctx context.Context, within time.Duration) { |
| + select { |
| + case got <- within: |
| + default: |
| + } |
| + }} |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + go a.tickLoop(ctx, every) |
| + |
| + select { |
| + case within := <-got: |
| + if within != every { |
| + t.Fatalf("a pass scheduled every %v was given %v to run in", every, within) |
| + } |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("the scheduling path never ran a pass") |
| + } |
| + |
| + // And the interval production runs on is the spec's. |
| if tickEvery != 15*time.Second { |
| t.Fatalf("tickEvery is %v, and spec §2.4 says fifteen seconds", tickEvery) |
| } |