From 090c993beea8c7895b3269f2a3d4f3433e8add5b Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Wed, 24 Jun 2026 20:08:26 +1000 Subject: [PATCH] fix(sdk): guard non-positive Every durations --- .changeset/quiet-timers.md | 5 +++ host/sdk/room.go | 4 +-- host/sdk/room_test.go | 69 ++++++++++++++++++++++++++++++++++++++ host/sdk/testroom.go | 3 ++ kit.go | 2 +- package-lock.json | 8 ++--- 6 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 .changeset/quiet-timers.md diff --git a/.changeset/quiet-timers.md b/.changeset/quiet-timers.md new file mode 100644 index 0000000..2eb32d6 --- /dev/null +++ b/.changeset/quiet-timers.md @@ -0,0 +1,5 @@ +--- +"kit": patch +--- + +Guard non-positive `Room.Every` durations so they return no timer instead of panicking, and fix stale package metadata. diff --git a/host/sdk/room.go b/host/sdk/room.go index 323fb62..eeeffea 100644 --- a/host/sdk/room.go +++ b/host/sdk/room.go @@ -350,7 +350,7 @@ func (rt *roomRuntime) handleHibernate(c command) { c.reply <- err // freeze failed: room stays live, ordering immaterial return } - c.reply <- nil // success reply BEFORE disposal cancels the ctx + c.reply <- nil // success reply BEFORE disposal cancels the ctx rt.disposeHibernated() // cancels rt.ctx; reply already delivered } @@ -819,7 +819,7 @@ func (rt *roomRuntime) scheduleTimer(id TimerID, d time.Duration) { } func (h *roomHandle) Every(d time.Duration, fn func(r Room)) TimerID { - if !h.valid() { + if !h.valid() || d <= 0 { return 0 } rt := h.rt diff --git a/host/sdk/room_test.go b/host/sdk/room_test.go index b18e477..55c8aa8 100644 --- a/host/sdk/room_test.go +++ b/host/sdk/room_test.go @@ -174,6 +174,75 @@ func TestCapacityEnforced(t *testing.T) { <-ctl.Done() } +type timerEdgeHandler struct { + Base + everyZeroID TimerID + everyNegID TimerID + afterZeroID TimerID + calls chan string +} + +func (h *timerEdgeHandler) OnStart(r Room) { + h.everyZeroID = r.Every(0, func(Room) { h.calls <- "every-zero" }) + h.everyNegID = r.Every(-time.Second, func(Room) { h.calls <- "every-neg" }) + h.afterZeroID = r.After(0, func(Room) { h.calls <- "after-zero" }) +} + +func TestEveryNonPositiveIsNoop(t *testing.T) { + h := &timerEdgeHandler{calls: make(chan string, 4)} + ctl := NewRoomRuntime("timer-edge", h, RoomConfig{Mode: ModeSolo, Capacity: 1}, Services{}) + defer ctl.Close() + + select { + case got := <-h.calls: + if got != "after-zero" { + t.Fatalf("first timer callback=%q, want after-zero", got) + } + case <-time.After(time.Second): + t.Fatal("After(0) did not fire") + } + if h.everyZeroID != 0 { + t.Fatalf("Every(0) id=%d, want 0", h.everyZeroID) + } + if h.everyNegID != 0 { + t.Fatalf("Every(-d) id=%d, want 0", h.everyNegID) + } + if h.afterZeroID == 0 { + t.Fatal("After(0) returned 0") + } + select { + case got := <-h.calls: + t.Fatalf("unexpected non-positive Every callback %q", got) + case <-time.After(50 * time.Millisecond): + } +} + +func TestTestRoomEveryNonPositiveIsNoop(t *testing.T) { + h := &timerEdgeHandler{calls: make(chan string, 4)} + r := NewTestRoomFor(h, RoomConfig{Mode: ModeSolo, Capacity: 1}, Services{}) + r.Start() + if h.everyZeroID != 0 { + t.Fatalf("Every(0) id=%d, want 0", h.everyZeroID) + } + if h.everyNegID != 0 { + t.Fatalf("Every(-d) id=%d, want 0", h.everyNegID) + } + r.Advance(0) + select { + case got := <-h.calls: + if got != "after-zero" { + t.Fatalf("first timer callback=%q, want after-zero", got) + } + default: + t.Fatal("After(0) did not fire in TestRoom") + } + select { + case got := <-h.calls: + t.Fatalf("unexpected non-positive Every callback %q", got) + default: + } +} + func recvFrame(t *testing.T, ch <-chan Frame) Frame { t.Helper() select { diff --git a/host/sdk/testroom.go b/host/sdk/testroom.go index a5efc22..dff84f6 100644 --- a/host/sdk/testroom.go +++ b/host/sdk/testroom.go @@ -180,6 +180,9 @@ func (t *TestRoom) After(d time.Duration, fn func(r Room)) TimerID { return t.nextID } func (t *TestRoom) Every(d time.Duration, fn func(r Room)) TimerID { + if d <= 0 { + return 0 + } t.nextID++ t.timers[t.nextID] = testTimer{fireAt: t.Clock.Add(d), period: d, fn: fn, every: true} return t.nextID diff --git a/kit.go b/kit.go index 3fef036..db52703 100644 --- a/kit.go +++ b/kit.go @@ -3,7 +3,7 @@ // the ABI's code form). // // A game implements Game + Handler and calls Main(game) from main(), plus the -// eight //go:export trampolines for the wasm build — run `gamekit new` for a +// eight //go:export trampolines for the wasm build — run `shellcade-kit new` for a // working scaffold, and see GUIDE.md for the full authoring guide. // // This package is a curated facade over internal/game; the implementation is diff --git a/package-lock.json b/package-lock.json index 8f2bb1d..aa41553 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "gamekit", - "version": "0.2.0", + "name": "kit", + "version": "2.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "gamekit", - "version": "0.2.0", + "name": "kit", + "version": "2.14.0", "devDependencies": { "@changesets/cli": "^2.27.0" }