oficina / meet Public

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
Paul Campbell pushed by paul@keymail.dev de597c640164a1bc0cae5b2af300694f68837a5e parent 3eec748
4 files changed, +125 −14
  • internal/meet/app.go +24 −3
  • internal/meet/hub/admission.go +12 −6
  • internal/meet/hub/relay_test.go +60 −0
  • internal/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)
}