🐛 fix: de-flake the clock-sensitive tests - #4575
Conversation
The memory storages compare entry expiry against utils.Timestamp(), a Unix second refreshed by a background 1s ticker, not against the wall clock. Under -race on a loaded runner that goroutine's store lands up to 1.6s late and is >100ms late on about half the ticks, so expiry can lag its nominal TTL by roughly two seconds. Tests that slept TTL plus 100-500ms and then asserted expiry were therefore flaky by construction. Reproduced at the storage level in 2 of 24 rounds with TTL=5s and a 5.1s sleep, and at test level as the exact main failures (session_test.go "Expected nil, but got: john" and Test_Session_WithConfig) in 2 of 8 rounds under GOMAXPROCS=2 with in-process load. Zero of 8 after. Add internal/clocktest.SleepPast, which sleeps the TTL and then waits for that cached clock to catch up, and use it where the cached clock is what decides: session idle timeout, csrf without a Session store, and limiter windows (which read cfg.currentSecond() and never time.Now()). Sleeping longer is not a fix, the updater delay has no upper bound. Left alone on purpose: the session absolute timeout and csrf with a Session store both expire against a real time.Now(), so a wall-clock sleep is correct there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test_CacheMaxStaleRespectsProxyRevalidateSharedAuth failed on main with expected "miss", actual "unreachable". Not the sleep: the store phase reads cfg.now() twice (cache.go:714 for nowUnix, cache.go:784 for ts) and charges the whole second in between as the response's apparent age. With s-maxage=1 that consumes the entire lifetime, so remainingExpiration hits zero and a brand new response is reported unreachable instead of being cached. Reproduced deterministically through the injectable clock: a request makes three cfg.now() calls, and moving the boundary to just after the second one turns a max-age=1 store into "unreachable" every time. Raising the lifetime to two seconds means a stolen second can no longer consume it, and the assertions are unchanged: after 2.5s the entry is stale either way, max-stale=30 would allow it, and proxy-revalidate still forces the revalidation this test is about. This is a workaround for the double clock read, not a fix for it. The same latent exposure remains in Test_CacheOnlyIfCachedStaleNotServed, Test_CacheMaxStaleRespectsMustRevalidate and Test_CacheStaleResponseAddsWarning110, which all assert a cache miss on a max-age=1 store. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ner load TestTimeout_Exceeded asserted elapsed < 150ms against a 50ms timeout and a 200ms handler sleep, and failed on main at 150.78ms. With only 100ms between the bound and the handler's own sleep, the assertion tracked how busy the runner was rather than whether the handler returned on cancelation. Raise the handler sleep to 2s and bound at half of it. That keeps what the test is for, a return well before the handler's own timer, with twenty times the timeout as slack, and costs nothing when it passes because the handler still returns after about 50ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
defaultStackTraceHandler wrote "panic: %v" to stderr for a panic it had just
recovered, a line byte-identical to the runtime's fatal banner. Besides being
misleading in any log, it silently disabled the Test workflow's rerun-fails.
gotestsum flags a package the moment any output line starts with "panic: "
(testjson/execution.go addOutput), and cmd/rerunfails.go aborts the rerun when
any package is flagged:
ERROR rerun aborted because previous run had a suspected panic and
some test may not have run
So this single line, reached only through EnableStackTrace with no custom
handler, cost the whole 4248-test suite its retries, and every flake on main
went red despite rerun-fails being set to 2. Because the write goes straight to
os.Stderr it also got misattributed to unrelated tests (golang/go#45508, the
issue gotestsum's mitigation cites).
Prefix the line with "recovered" and pin it with a test that captures the
handler's output through an os.Pipe and asserts it no longer looks fatal, so
this cannot regress unnoticed again.
Verified with gotestsum v1.13.0 and a deliberately failing test: before, the
rerun aborted; after, re-run 1 and re-run 2 both ran over the full suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
WalkthroughAdds a cached-clock test helper and applies it to middleware expiry and retry tests. Cache timing assertions are adjusted, recovered panic output gains a non-fatal prefix with coverage, and timeout tests derive bounds from a named duration. ChangesMiddleware test reliability
Estimated code review effort: 3 (Moderate) | ~20 minutes Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 golangci-lint (2.12.2)level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies" 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 |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4575 +/- ##
==========================================
+ Coverage 93.25% 93.31% +0.05%
==========================================
Files 140 140
Lines 14856 14858 +2
==========================================
+ Hits 13854 13864 +10
+ Misses 625 619 -6
+ Partials 377 375 -2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Reduces CI flakiness in clock-sensitive tests by introducing a helper that waits on Fiber’s cached second clock (utils.Timestamp-based expiry), and restores GitHub Actions gotestsum reruns by ensuring the recover middleware’s stack trace output no longer looks like an unrecovered panic.
Changes:
- Add
internal/clocktest.SleepPastand use it in session/CSRF/limiter tests that are governed by the cached second clock. - Make
recover’s default stack trace output start withrecovered panic:and add a regression test to prevent gotestsum rerun suppression. - Increase timing headroom in timeout and cache tests to avoid runner-load-driven flakes.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| middleware/timeout/timeout_test.go | Widens timing bounds so the test measures early-cancel behavior rather than runner load. |
| middleware/session/session_test.go | Replaces wall-clock sleeps with cached-clock-aware waits for idle-timeout expiry checks. |
| middleware/session/middleware_test.go | Uses cached-clock-aware waits for session idle-timeout behavior. |
| middleware/recover/recover.go | Changes default stack trace prefix to avoid gotestsum misclassifying recovered panics as crashes. |
| middleware/recover/recover_test.go | Adds a test that captures stderr and asserts the new non-fatal prefix. |
| middleware/limiter/limiter_test.go | Uses cached-clock-aware waiting when sleeping past Retry-After windows. |
| middleware/csrf/csrf_test.go | Uses cached-clock-aware waits for token expiry in non-session CSRF mode; clarifies session-backed expiry uses real clock. |
| middleware/cache/cache_test.go | Increases s-maxage/sleep to avoid a known second-boundary issue in cached apparent-age math during store. |
| internal/clocktest/clocktest.go | Introduces the cached-clock-aware wait helper used by the updated tests. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
internal/clocktest/clocktest.go (1)
18-24: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winGuard or widen the TTL before waiting for the cached timestamp.
uint32(ttl.Seconds())floors under one second, so sub-second TTLs makewant == fromand the loop exits immediately. Values >=uint32seconds overflow to0. Document a positive whole-second contract and use a wider/correct conversion for the target when durations above one second are supported.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/clocktest/clocktest.go` around lines 18 - 24, Update SleepPast to handle TTL conversion safely: enforce or document a positive whole-second TTL contract, and prevent uint32(ttl.Seconds()) from flooring sub-second values or overflowing for large durations. If durations above one second remain supported, compute the target timestamp with a wider or otherwise correct conversion before waiting.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/clocktest/clocktest.go`:
- Around line 25-29: Update SleepPast in the clocktest helper so reaching its
deadline is observable instead of returning silently; after the wait loop,
detect that utils.Timestamp() is still below want and fail the test or return a
status that every caller asserts. Preserve the existing successful path once the
cached clock reaches the target.
---
Nitpick comments:
In `@internal/clocktest/clocktest.go`:
- Around line 18-24: Update SleepPast to handle TTL conversion safely: enforce
or document a positive whole-second TTL contract, and prevent
uint32(ttl.Seconds()) from flooring sub-second values or overflowing for large
durations. If durations above one second remain supported, compute the target
timestamp with a wider or otherwise correct conversion before waiting.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: d2a12f2e-1d4b-4637-97d3-ed1a4f7592e0
📒 Files selected for processing (9)
internal/clocktest/clocktest.gomiddleware/cache/cache_test.gomiddleware/csrf/csrf_test.gomiddleware/limiter/limiter_test.gomiddleware/recover/recover.gomiddleware/recover/recover_test.gomiddleware/session/middleware_test.gomiddleware/session/session_test.gomiddleware/timeout/timeout_test.go
SleepPast returned silently once its 10s deadline passed, so a stuck timestamp updater surfaced as a confusing "entry not expired" assertion in the caller. It now takes a testing.TB and fails there, and the doc comment says that the wait is truncated to the whole seconds the storages compare against. Restore the swapped os.Stderr in the recover test via defer as well, so an unexpected panic in the handler cannot leak the redirect into the rest of the package.
Description
mainhas been going red on isolated test failures for months, and the retry thatshould absorb them was never running. This fixes both.
Cause 1, the cached clock. The memory storages compare entry expiry against
utils.Timestamp(), a Unix second refreshed by a background 1s ticker, not against thewall clock. Under
-raceon a loaded runner that goroutine's store lands up to 1.6s late,and >100ms late on about half the ticks, so expiry can lag its nominal TTL by roughly two
seconds. Every test that slept
TTL + 100..500msand then asserted expiry was flaky byconstruction.
Reproduced at the storage level in 2 of 24 rounds (TTL=5s, 5.1s sleep), and at test level
as the exact failures seen on main in 2 of 8 rounds under
GOMAXPROCS=2with in-processCPU load. Zero of 8 with the fix. Note the load has to be in-process, an external load
generator does not starve Go's own scheduler and shows nothing.
Cause 2, dead reruns.
test.ymlsetsrerun-fails: '2', but every run aborted withrerun aborted because previous run had a suspected panic and some test may not have run.gotestsum flags a package as panicked the moment any output line starts with
panic:(
testjson/execution.goaddOutput) andcmd/rerunfails.goskips the rerun if anypackage is flagged. The recover middleware wrote exactly that prefix for a panic it had
just recovered, so one line cost the whole 4248-test suite its retries.
Affected runs: 30523196027,
30397573119,
28373765289,
28302993228,
28206382069.
Changes introduced
test(session,csrf,limiter)Addinternal/clocktest.SleepPast, which sleeps the TTLand then waits for the cached clock to catch up, and use it where that clock is what
decides. Sleeping longer is not a fix, the updater delay has no upper bound.
I checked per call site which clock actually governs, because the helper shortens the
wall-clock margin and that is only safe where the cached clock decides:
SavetoStorage.Set(.., idleTimeout))absExpiration)time.Now()Session(storageManager,internal/memory)Session(token.Expiration.Before(time.Now()))time.Now()cfg.currentSecond())time.Now()at allThe two real-clock paths are deliberately left on a plain
time.Sleep.test(cache)Test_CacheMaxStaleRespectsProxyRevalidateSharedAuthfailed withexpected
miss, actualunreachable. A different mechanism: the store phase readscfg.now()twice (cache.go:714andcache.go:784) and charges the whole second inbetween as the response's apparent age, which consumes a one-second lifetime entirely.
Raising it to two seconds makes the test immune without changing what it asserts.
test(timeout)TestTimeout_Exceededbounded elapsed at 150ms against a 50ms timeoutand a 200ms handler sleep, and failed at 150.78ms. Only 100ms of headroom meant it
measured runner load, not early return. Handler sleep to 2s, bound at half of it.
fix(recover)Prefix the default stack trace withrecovered, and pin it with a testthat captures the output through an
os.Pipeand asserts it no longer looks fatal.Open question for maintainers
The cache double clock read is a real bug, not just a test annoyance, and I did not touch
it here. A response with
max-age=1fails to be cached at all whenever the store phasecrosses a second boundary, because
responseTS - e.datecharges fiber's own processinglatency as response age. RFC 9111 defines apparent age against the same response event,
so measuring it from a later clock read manufactures age out of nothing.
Deterministic reproduction through the injectable clock: a request makes three
cfg.now()calls, and moving the boundary to just after the second one turns a
max-age=1store intounreachableevery time.Fixing it is about two lines (measure the age against
nowUnix, keepresponseTSas theexpiry anchor) and would remove the same latent exposure from
Test_CacheOnlyIfCachedStaleNotServed,Test_CacheMaxStaleRespectsMustRevalidateandTest_CacheStaleResponseAddsWarning110, which all assert a cache miss on amax-age=1store. It also lets the workaround in this PR be reverted. I left it out because it
changes RFC-adjacent freshness math and deserves its own change with its own regression
test. Happy to open that separately.
Type of change
Note for the release notes:
recover's defaultStackTraceHandlernow writesrecovered panic: ...instead ofpanic: ...to stderr. Not an API change and theformat was never documented, but anyone grepping their logs for that prefix will notice.
EnableStackTracedefaults tofalse, so most users never see the line at all.Checklist
/docs/directory. Not needed,docs/middleware/recover.mddocumentsEnableStackTraceandStackTraceHandlerbut not the output format.Verification
-race -count=1 -shuffle=ongreen,gofmtandgo vetclean.GOMAXPROCS=2, in-process load,-race -count=8):3 failures before, 0 after. recover package 20x
-race -shuffle=on.rerun aborted, afterre-run 1andre-run 2both ran,DONE 3 runsover the full suite.grep -c '^panic: 'over the whole suite output is now 0.One honest gap:
Test_CSRF_ExpiredTokenheld 8 of 8 in the before state under the sameharness that reliably broke the session tests, so I could not reproduce that one locally.
Its 250ms margin makes it a rarer variant of the same class. The evidence there is the
code path (
internal/memory, cached clock) plus the CI signature from run 28206382069(
expected 403, actual 200, token not expired).The limiter failures that harness produces (
app.Testtimeouts on tests with 200-300msExpiration) are pre-existing. They reproduce identically without this change, none sitat a
sleepForRetryAftercall site, and none appear in CI history.🤖 Generated with Claude Code