feat(relay): schedule deferred binary release with grace-period reclaim (#20)#22
Conversation
…im (#20) Adds Registry.ScheduleReleaseServer arming a timer that, on expiry, removes the binary entry and closes phones outside the registry lock. ClaimServer gains a grace-aware fast path: a reclaim within the window cancels the pending timer and atomically replaces the binary Conn, returning nil instead of ErrServerIDConflict. Phones registered during the grace window inherit the new binary on reclaim or are closed on expiry. The expiry handler uses pointer-identity on the timer entry to detect stale fires (timer already-firing while a Stop+replace runs under the lock). The wrapper struct (graceEntry) lets the closure capture a stable pointer set before AfterFunc returns, avoiding the self-referential local-var race. Existing ReleaseServer (synchronous) is unchanged. No callers migrate in this slice; the /v1/server handler swap lives in the sibling ticket. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Code Review: #20Decision: PASS Findings
SummaryFaithful implementation of the spec. The pointer-identity stale-fire defense is correct, the lock discipline matches ADR-0003 (single mutex over all three maps), Security goggles applied (label: Doc-comment polish before merging would be ideal but is not blocking. |
Document ScheduleReleaseServer and the grace-window-as-reclaim semantics shipped in #20. New ADR-0006 captures why a ClaimServer during a pending grace timer succeeds rather than conflicts, and the pointer-identity defence against stale time.AfterFunc fires after Stop() returns false. - ADR-0006: grace window IS the reclaim path - features/connection-registry.md: ScheduleReleaseServer surface, lock graph (now three maps), goroutine lifecycle, expanded testing section - INDEX.md: ADR-0006 + updated registry one-liner - PROJECT-MEMORY.md: grace-period entry; new pattern (pointer-identity for stale AfterFunc fires) - lessons.md: Stop() returning false during in-flight fn; why we capture a wrapper pointer rather than *time.Timer Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
What
Adds
Registry.ScheduleReleaseServer(serverID, d)— a deferred-release primitive that, on timer expiry, removes the binary entry and closes phones outside the registry lock (snapshot-and-iterate, same shape asPhonesFor).ClaimServergains a grace-aware fast path: when a timer is pending forserverID, the claim cancels the timer and atomically replaces the binaryConn, returningnilinstead ofErrServerIDConflict. The grace window IS the reclaim path — phones registered during the window inherit the new binary on reclaim, or areClose()'d on expiry.ReleaseServer(synchronous) is unchanged. No callers migrate in this slice; the/v1/serverhandler swap lives in #21.Issue
Closes #20. Split from #8.
Testing
Seven new subtests in
internal/relay/registry_test.go, mapped 1:1 to AC bullets:TestScheduleReleaseServer_ReclaimWithinGrace_NoReleaseFiresTestScheduleReleaseServer_ExpiryRemovesBinaryAndClosesPhonesTestScheduleReleaseServer_PhoneRegisteredDuringGrace_SurvivesReclaimTestScheduleReleaseServer_PhoneRegisteredDuringGrace_ClosedOnExpiryTestScheduleReleaseServer_ReplacesPendingTimerTestScheduleReleaseServer_OnUnheldID_NoOpTestScheduleReleaseServer_RaceFreedomUnderRapidCycles— 16 goroutines × 200 ops hammering claim/reclaim/schedule/cancel; clean under-race -count=20.fakeConn.closedis now mutex-guarded with an optionalcloseChfor deterministic synchronisation in expiry tests.go test -race ./...,go vet ./...,go build ./...all clean.Architecture compliance
sync.RWMutexcontinues to guard all maps (now three) per ADR-0003.graceEntry); using*time.Timerdirectly tripped the race detector because the closure captures the localtvariable assigned aftertime.AfterFuncreturns. Wrapping ingraceEntrylets the closure capture a stable pointer set before the timer is created.PhonesForsnapshot pattern.d<=0, very large) are safe per security review.🤖 Generated with Claude Code