Repository navigation
test: drive the in-memory rate limiter and the shutdown wait test without wall-clock sleeps - #1568
Conversation
…hout wall-clock sleeps Remove the last Go test sleeps that stood in for async synchronization: - api: InMemoryRateLimiter gets an injectable now func (nil means time.Now, as with Handler.now). The window-expiry test uses a one-hour window and a fake clock, and pins that a request at exactly the reset time is still blocked. The garbage-collection test, which asserted nothing and never reached the cap, now fills the map to the cap, expires the short-window entries by advancing the clock, and checks the purge keeps only the live entry and the new one. - server: shutdownInitWaitHook (nil in production) runs right before StartHTTPServer waits on the background initializer. The shutdown test takes the goroutine ID from the hook and waits until the goroutine dump shows it parked in that select, instead of watching for 300ms. Refs LeanerCloud#36 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 59 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rI1. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 1 approving independent review(s) on |
Summary
This finishes the Go part of #36 (wall-clock sleeps used for async synchronization in tests). #1298 already removed most of the Go sleeps. This PR handles the three that were left:
internal/api/inmemory_rate_limiter_test.go:TestInMemoryRateLimiter_Allow_WindowExpiryslept 20ms to wait out a 10ms window, andTestInMemoryRateLimiter_GarbageCollectionslept 10ms to wait out a 1ms window. The limiter readtime.Now()directly, so a sleep was the only way to expire a window.internal/server/http_shutdown_test.go:TestStartHTTPServerWaitsForBackgroundInitOnShutdownshowed that "StartHTTPServer does not return while the initializer is still running" by watching for 300ms. On a slow runner a wrong implementation could pass inside that window.Root cause and fix
Rate limiter clock.
InMemoryRateLimiternow has an injectablenow func() time.Timefield, read throughrl.clock(). This is the same pattern asHandler.now/h.clock(). Nil, the production default, meanstime.Now, so struct literals andNewInMemoryRateLimiter()behave exactly as before. A test-onlyfakeClockmoves time forward withAdvance.TestInMemoryRateLimiter_Allow_WindowExpirynow uses a one-hour window, so only the injected clock can expire it. It also pins the boundary: at exactlyresetTimethe request is still blocked (now.Afteris strict), one nanosecond later it is allowed, and the new window enforces the limit again.TestInMemoryRateLimiter_GarbageCollectionused to assert nothing beyond "no error". It also used only 26 distinct keys, so it never reached the cap path it was named for. It now fills the map toinMemoryRateLimitMaxEntries, with one long-window entry (the oldest) and the rest on a short window. It then advances the clock past the short window and triggers the cap check. It asserts that the expired-purge step alone brings the map down to 2 entries. Oldest-first eviction would leave 401 and would drop the live entry. It also asserts that the live entry keeps its count.Shutdown wait signal.
internal/server/http.gogets a package-levelshutdownInitWaitHook func(). It is nil in production, and tests set it the same way they already shortenshutdownGracePeriod. When set, it runs on the StartHTTPServer goroutine right before theselectoninitDone, which is afterserver.Shutdownand the scheduled-task stop. The test:internal/testutil/goroutines(added in test: wait on goroutines and barriers instead of wall-clock sleeps in Go tests #1298), until that goroutine is parked in[select]withinternal/server.StartHTTPServeras its innermost frame. After the hook, thatselectis the wait for the initializer.returnedis empty. A goroutine parked in that select cannot return untilinitDonecloses, andinitDonecannot close while the resolver is held. (The 30s grace period is the only other way out.)No time window decides the result. The remaining
time.After(5 * time.Second)calls are failure deadlines only. The test went from about 0.30s to about 0.00s.Alternatives considered: a hook alone, followed by a non-blocking check on
returned, races with a mutant that keeps the hook and drops the wait. Inspecting goroutines alone, with no hook, cannot tell the firstselect(serve vs. signal) apart from the init-waitselectwithout matching line numbers. Using both together makes the check deterministic.Refs #36
Verification
All evidence here is local unit tests on Linux (Go 1.26.9). None of it needs a database or a cloud API. Commands were run with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true.Fail-before: the rewritten tests use
rl.nowandshutdownInitWaitHook, so they do not compile againstorigin/main. Fail-before is therefore shown with mutation probes on the production code. Each probe was applied, run, and reverted:Allowreadstime.Now()instead ofrl.clock()go test ./internal/api/ -run 'InMemoryRateLimiter_(Allow_WindowExpiry|GarbageCollection)' -count=1resetTime(!now.Beforeinstead ofnow.After)go test ./internal/api/ -run InMemoryRateLimiter_Allow_WindowExpiry -count=1evictIfAtCapgo test ./internal/api/ -run InMemoryRateLimiter_GarbageCollection -count=1initDonewait (hook kept)go test ./internal/server/ -run 'TestStartHTTPServerWaitsForBackgroundInitOnShutdown$' -count=20initDonewait moved beforeserver.Shutdown-count=20Pass-after and stability:
Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree:
queue/platform@c00ef619= origin/main15dc8febplus these heads, merged in this order:Linux, Go 1.26.9,
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers. The full suite ran on644bfcaf, which differs from this tree only by a test-only rename on fix/issue-134-mfa-token (a password-like constant that git-secrets flagged):go build ./...,go vet ./...,go vet -tags integration ./...and the fullgo test -race -short -count=1 ./....--build-tags integration.gocyclo -over 10is silent on the changed non-test files.-tags integration -count=1):internal/api,internal/auth,internal/config,internal/database/postgres/migrations,internal/purchaseandinternal/serverall pass. Migration 000152 applies on top of main's 000151.scripts/setup-git-secrets.shpatterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged.scripts/test-git-secrets-allowlist.sh: 183 PASS, 0 FAIL..tftest.hclfiles were not run on this tree (CI runs them). Nothing was applied to any cloud.Out of scope
setTimeoutwaits in TEST-04: Wall-clock sleeps for async synchronization (~90 frontend setTimeout waits, several Go sleeps) #36 (frontend/src/__tests__/...) are handled on another branch by the UI owner. This PR changes nothing underfrontend/.startServerWithBlockedInit, which waits for the listener to come up and is not a negative-assertion window.TestStartHTTPServerShutdownWaitIsBoundedstill uses a 200msshutdownGracePeriod. It tests the real grace-period bound, so that elapsed time is the thing under test.Maintainers: please mirror the issue's triage labels on this PR:
impact/internal,type/chore,triaged,priority/p3,severity/low,urgency/eventually,effort/m.🤖 Generated with Claude Code