Add inactivity-based session timeout (closes #66) #105
Reference in New Issue
Block a user
Delete Branch "issue-66-session-idle-timeout"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #66.
Sessions had only a 7-day absolute lifetime, so an abandoned session stayed usable for the full week. Worse, that cap was enforced only by the cookie's
MaxAge— i.e. only by the browser.sessions.NewCookieStoregives its codecs a 30-day max age, and the app replacesstore.Optionswholesale afterwards, which does not touch the codecs; a client that simply kept sending the cookie was never told no by the server. Both clocks are now server-enforced.Idle vs absolute: two clocks, deliberately separate
Two timestamps live in the session, as Unix seconds:
created_at(CreatedAtKey)SetUser, once, at logincreated_at + 7d(sessionAbsoluteMaxAge, the existingsessionMaxAgeDays)last_seen(LastSeenKey)SetUser, thenTouchon activitylast_seen + SESSION_IDLE_TIMEOUTSession.expiredchecks the absolute deadline first and the idle deadline second, and the session ends at whichever comes first. Nothing rewritescreated_at— that is the whole point of a cap, and the constant's doc comment says so.Touchwriteslast_seenand nothing else.IsAuthenticatednow consultsexpired. That is the single choke point every existing caller already goes through — the auth middleware,handlers/index.go,handlers/auth.go,handlers/profile.go,source_management.go— so there is no route that honours one clock and not the other, and no call site had to be taught about expiry.Fail-closed detail: an authenticated session carrying no timestamps (a cookie minted before this change) is treated as expired. The upgrade costs one forced re-login rather than handing out an unbounded session.
What counts as activity, and where it is refreshed
Activity is a request that passes
RequireAuth.Touchis called there and only there, afterIsAuthenticatedhas already succeeded, and the session is saved before the wrapped handler runs so the headers are still ours to write.An unauthenticated request cannot refresh anything: the middleware takes the redirect branch and returns before reaching
Touch, and no public route (the webhook receiver, the login page,/) calls it at all. So nobody can keep someone else's session alive by pointing a stolen or abandoned cookie at a public endpoint.Touchdoes not rely on that: it re-checksIsAuthenticateditself and returns false for any session that is unauthenticated or already expired, so it cannot revive a dead session either.Write-amplification tradeoff
These are cookie sessions, so "saving" means re-encrypting the session and emitting a
Set-Cookieon the response. Refreshing on literally every authenticated request would do that on every response.Instead
Touchrewriteslast_seenonly once it is older thanidleTimeout / idleRefreshDivisor(a tenth of the window), and returns a bool so the middleware saves only when something actually changed. An actively used session is therefore re-issued at most ten times per idle window instead of once per request.The chosen consequence:
last_seenlags the user's real last request by up to a tenth of the window, so a session can expire up to 10% early — at the 24h default, up to ~2.4h early in the worst case. It can never expire late, since the stored timestamp is always at or before the true last request. Ten was picked as the point where the lag is small relative to a day-long window while the write rate is bounded by a constant rather than by traffic.The cookie's own
MaxAgeis deliberately left at the 7-day absolute cap rather than being retuned to the idle window; the server-side timestamps are the enforcement, and the cookie lifetime is only a browser-side hint.Configuration
SESSION_IDLE_TIMEOUT, default24h, parsed with the existingenvDurationhelper, so a set-but-unparseable value aborts startup instead of silently falling back (matching #78/#80). Non-positive disables idle expiry and leaves the absolute cap as the only bound.The
internal/configchange is deliberately three lines — one constant, oneConfigfield, one parse call — and restructures nothing, so the rebase over #92'sloadFromEnvis mechanical.Tests
All expiry tests use an injected clock (
Session.now, threaded throughNewForTest); no sleeps. Fullmake teston a cold cache: 11.2s, well inside the 20s policy.internal/session:TestIsAuthenticated_IdleExpired/_WithinIdleWindow— the window boundaryTestTouch_DoesNotExtendAbsoluteCap— the refresh-the-wrong-clock regression test. Drives 335 requests, one every half idle window, across the whole 7 days, asserting the session survives each; then one more step lands exactly on the absolute cap and the session must be dead, withcreated_atbyte-identical to its login valueTestTouch_RefreshesIdleDeadline— refreshed session outlives the original deadline and dies one window after the activityTestTouch_LazyBelowRefreshThreshold— no rewrite below the thresholdTestTouch_UnauthenticatedSessionIsNotRefreshed,TestTouch_IdleExpiredSessionIsNotRevivedTestIsAuthenticated_MissingTimestamps/_MissingLastSeen— fail closedTestIdleTimeoutDisabled_AbsoluteCapStillAppliesTestSetUser_StartsBothClocks,TestClearUser_RemovesTimestampsinternal/middleware:TestRequireAuth_IdleExpiredSession_RedirectsToLogin— and asserts no session cookie is emitted, so an expired cookie cannot be revived by hitting a protected routeTestRequireAuth_RefreshesIdleDeadlineOnActivity— the re-issued cookie works past the original deadline while the pre-refresh cookie does notTestRequireAuth_UnauthenticatedRequestDoesNotRefreshinternal/config:TestSessionIdleTimeout(default / parsed / unparseable-aborts-startup), mirroring theRETENTION_SWEEP_INTERVALtable test. Its shared "startup must fail" helper was renamedtestRetentionSweepIntervalError->expectStartupErrornow that two tests use it.Mutation evidence
Three mutations, each applied alone, reverted after (
diffagainst a pre-mutation copy confirmed byte-identical restore):expired(return falsein its place) — 6 failures:TestIsAuthenticated_IdleExpired,TestIsAuthenticated_MissingLastSeen,TestTouch_RefreshesIdleDeadline,TestTouch_IdleExpiredSessionIsNotRevived,TestRequireAuth_IdleExpiredSession_RedirectsToLogin,TestRequireAuth_RefreshesIdleDeadlineOnActivity.sess.Values[CreatedAtKey] = now.Unix()toTouch, so activity refreshes the absolute clock too —TestTouch_DoesNotExtendAbsoluteCapfails on both assertions, the behavioural one (Should be false/ "activity must not extend the absolute cap") and the anchor one (expected: 1767323045, actual: 1767926045/ "Touch must never rewrite the absolute-clock anchor"). Worth noting: my first draft of that test was caught only by the white-box anchor assertion, because it jumped a full 7 days at the end and so expired even under the mutation. The test was rewritten to advance exactly one more step to the true cap, which is what makes the behavioural assertion bite.Touch(the unauthenticated-refresh hazard) —TestTouch_UnauthenticatedSessionIsNotRefreshedandTestTouch_IdleExpiredSessionIsNotRevivedfail.Verification
make fmtrun;make checkexit 0 (test + lint + fmt-check), lint0 issues, all lint output confined to this worktree with no parallel-lock error.script/cibuildexit 0 in 4m02s wall — a genuine execution, not a cache serve: the commit changed the tree, soCOPY . .invalidated the layers andmake fmt-check,make lint(pinned golangci-lint v2.12.2) andmake testall ran inside the image. A confirming second run immediately after reports every one of those layersCACHED, which is only possible because the first run really produced them..golangci.ymluntouched — sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb. Dockerfile still pinned to golangci-lint v2.12.2 at its digest.TODO.mdupdated in the same commit; markdown formatted viamake fmt.Not done here
Regeneratecopies session values and is only ever called at login, immediately beforeSetUser, which re-stamps both clocks — so a login correctly resets the absolute cap. No other caller exists, and session handling is otherwise unchanged.What I built
One commit,
b04bc2c, onmain@4f5ecb1.Sessions now carry two timestamps and are bounded by two clocks that cannot be confused for one another:
created_atis stamped once bySetUserat login and anchors the absolute 7-day cap. Nothing rewrites it.last_seenanchors the idle window (SESSION_IDLE_TIMEOUT, default24h) and is pushed forward by the newSession.Touch.Session.expiredchecks both and the session dies at whichever deadline arrives first.IsAuthenticatedconsults it, which puts the check on the one path every caller already takes, so no handler needed changing and none can skip it.Touchis called from exactly one place,RequireAuth, after authentication has already succeeded. Unauthenticated requests return on the redirect branch before reaching it and no public route calls it at all, so a cookie pointed at a public endpoint cannot keep a session alive.Touchadditionally refuses any session that is not currently authenticated and unexpired, so that property does not rest on the call site behaving.Touchrewriteslast_seenonly once it is older than a tenth of the idle window, and returns a bool so the middleware re-issues the cookie only when something changed. Deliberate cost: the stored timestamp lags real activity by up to that tenth, so a session can expire up to 10% early — never late.Also fixed as a side effect: the absolute cap used to be enforced only by the cookie's
MaxAge, i.e. only by the browser, becauseNewCookieStoreleaves its codecs at 30 days and replacingstore.Optionsdoes not change them. Both deadlines are now server-side.internal/configgains exactly one constant, one field and oneenvDurationcall, so #92'sloadFromEnvrewrite rebases mechanically.How I verified it
make checkexit 0 — test + lint + fmt-check. Lint reported0 issues, with no output referencing anything outside this worktree and no parallel-lock error.make testfull cold-cache run: 11.2s, inside the 20s policy. All expiry tests use an injected clock; no sleeps anywhere.script/cibuildexit 0 in 4m02s wall, and it genuinely ran rather than being served from cache: the commit changed the tree, soCOPY . .invalidated the downstream layers andmake fmt-check,make lint(pinned golangci-lint v2.12.2) andmake testall executed inside the image. The confirming signal is that an immediate secondscript/cibuildreports those same layersCACHED, which is only possible because the first run produced them.check / check (push)on headb04bc2c— run 107..golangci.ymluntouched, sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb. Dockerfile still pins golangci-lint v2.12.2 by digest.Mutation evidence
Each mutation applied on its own and reverted afterwards, with the restored file confirmed byte-identical to a pre-mutation copy.
expired-> 6 tests fail, includingTestIsAuthenticated_IdleExpiredand both middleware idle tests.Touchalso writescreated_at— the exact bug the issue warns about ->TestTouch_DoesNotExtendAbsoluteCapfails on the behavioural assertion ("activity must not extend the absolute cap") and on the anchor assertion ("Touch must never rewrite the absolute-clock anchor"). My first draft of that test only caught it via the anchor assertion, because it advanced a full 7 days at the end and so expired even under the mutation; it now advances exactly one more step onto the true cap, which makes the behavioural assertion bite.IsAuthenticatedguard removed fromTouch->TestTouch_UnauthenticatedSessionIsNotRefreshedandTestTouch_IdleExpiredSessionIsNotRevivedfail.Correction on the CI line above, so nothing here reads as a green I did not earn.
Gitea CI run 107 (
check / check (push)) on headb04bc2cis still reported aspending/ "Waiting to run" seventeen minutes after the push — it has not started, let alone passed. The shared runner is saturated at the moment. I am not going to sit on the task waiting for it, so: no CI verdict exists for this commit yet. Whoever reviews should confirm run 107 actually went green before treating CI as evidence.What I can stand behind is local:
make checkexit 0, and ascript/cibuildthat genuinely executed for 4m02s (the commit invalidatedCOPY . ., somake fmt-check,make lintandmake testall ran inside the pinned v2.12.2 image; an immediate second run reports those layersCACHED, which is only possible because the first run produced them).Lint provenance, re-verified against the pushed head commit
b04bc2cin a fresh worktree checked out from the remote branch:golangci-lint has version 2.12.2— identical to the Dockerfile pin, so there is no host/CI version skew and no reason to discount anything as a version artefact.make lintexit 0, output in full: the twogomodguarddeprecation warnings (tracked in #98) and0 issues.Noparallel golangci-lint is running, and no reported file paths at all — nothing relative (../), nothing absolute, nothing outside the worktree.make checkexit 0 on the same tree, with every package's tests actually running rather than served from the Go test cache (ok ... 1.049sand friends, no(cached)). The only/tmppaths anywhere in that output aret.TempDir()data directories in the database tests' log lines, not lint findings.For the record on how the two findings I did fix arose: the first
make lintof this change reported exactly two,funcorderoninternal/session/session.goandnoinlineerroninternal/middleware/middleware.go— both in files this change authors, both in-worktree, neither relative-pathed. They were mine and I fixed them rather than explaining them away. NogosecG704 appeared at any point in this work.CI verdict now exists, resolving my previous comment: run 107 (
check / check (push)) on headb04bc2creports success, "Successful in 3m6s".3m6s is a real execution — the runner starts from a cold Docker cache, so
make fmt-check,make lintandmake testall genuinely ran inside the pinned v2.12.2 image. A cache-served no-op would have come back in under a second.So the evidence for this commit is complete: CI green on the head commit, plus the local
make checkand the 4m02sscript/cibuild. Nothing in this PR now rests on a green I could not demonstrate.Review: PASS
Independent adversarial review of
b04bc2cagainstmain@4f5ecb1. Reviewed in a dedicated throwaway worktree; nothing in the PR was modified.Verification runs — what actually executed
make checkrun 1: VOID. Reported 24 issues, every path prefixed../wt-issue-66-verify/, i.e. attributed to a worktree outside the one I launched from, with cache-read failures for files that do not exist. Discarded, not counted as a finding. Wall 8.9s.make checkrun 2 aftergolangci-lint cache clean: VALID and green. Exit 0, lint0 issues., every message confined to my worktree, no../paths, no parallel-lock error. Wall 12.0s. (Only the deprecation notice forgomodguard, which is pre-existing onmain.)make testwith the Go test cache bypassed: 4.5s wall, 252 subtests pass, all 8 packagesok, none near the 30s per-package timeout. Comfortably inside the 20s policy inREPO_POLICIES.md. Confirms the author's 11.2s figure with margin.script/cibuild: exit 0 in 0.30s wall, ALL 22 layersCACHED, includingRUN make test,RUN make lint,RUN make fmt-check. This run is NOT evidence — the tree was unchanged, so Docker served the whole build. Stating this plainly rather than claiming it.check / check (push)onb04bc2c= success in 3m6s (run 107).mergeable: true, andb04bc2cis a descendant oforigin/main— fast-forward, no rebase needed.Scrutiny items, each resolved
1. Two clocks genuinely independent — VERIFIED BY EXECUTION. Reproduced the author's mutation 2 (added
sess.Values[CreatedAtKey] = now.Unix()toTouch).TestTouch_DoesNotExtendAbsoluteCapfails on both assertions, exactly as claimed:Should be false/ "activity must not extend the absolute cap", andexpected: 1767323045, actual: 1767927845/ "Touch must never rewrite the absolute-clock anchor". Restored byte-identical afterwards.CreatedAtKeyis written in exactly one place,SetUser; grepped every write path.2. The rewritten test's behavioural assertion genuinely bites — VERIFIED. The arithmetic is exact, not approximate:
step = 1h/2 = 30m,steps = int(168h/30m) - 1 = 335, loop advances167h30m, the final step lands on168h0m0s, which is preciselysessionAbsoluteMaxAge.expireduses!now.Before(deadline), so exactly-on-the-cap is dead. Both the integer division and the boundary are exact — there is no rounding and no off-by-one flakiness window. The mutation run above proves the behavioural assertion fires independently of the anchor assertion.testAbsoluteMaxAgeis restated independently of the implementation constant, which is the right call.3. Unauthenticated requests cannot refresh — BOTH LAYERS VERIFIED, plus independent route enumeration.
Touchis called from exactly one place in non-test code,internal/middleware/middleware.go:216, after theIsAuthenticatedguard returns.Touchitself re-checksIsAuthenticatedand returns false for unauthenticated or already-expired sessions.internal/server/routes.gomyself rather than trusting the PR body. Public:/,/s/*,/api/v1(empty),/.well-known/healthcheck,/metrics(basic auth, no session),/pages/loginGET+POST,/pages/logout,/webhook/{uuid}. None reachesTouch. BehindRequireAuth:/user/{username}/*,/sources/*,/source/{sourceID}/*. Note/pages/logoutis deliberately public and only destroys — no refresh path. A cookie aimed at any public endpoint cannot extend a session.4. The claimed pre-existing hole is REAL — VERIFIED BY EXECUTION AND BY SOURCE.
gorilla/sessions@v1.4.0NewCookieStoresetsOptions.MaxAge = 86400 * 30and then callscs.MaxAge(...), which is the only thing that propagates the bound intoCodecs.session.Newafterwards assignsstore.Options = &sessions.Options{...}wholesale, which never touches the codecs. I confirmed the consequence by running a throwaway probe (since removed): with the Options-replacement pattern a cookie whoseOptions.MaxAgeis 1s still decodes after 3s (true), whilestore.MaxAge(1)correctly rejects it (false). So before this change the 7-day cap was browser-side only and the server would have honoured a cookie for up to 30 days. The PR body does not overstate; this is a genuine security fix and belongs in the merge record.5. Lazy refresh direction — VERIFIED.
lastSeenis only ever written tos.now()at the moment of a real authenticated request, so it can never lead the true last request; expiry is therefore never late. The no-write band is measured from the storedlastSeen, not from the previous request, so a request pattern that always lands just inside the band cannot starve the write: oncenow - lastSeenreachesidleTimeout/10the next request writes regardless of pacing. Bound on earliness is therefore< idleTimeout/idleRefreshDivisor= 10% of the configured window (s.idleTimeout/idleRefreshDivisor, not a constant). No indefinite-liveness path.6.
expiredon the read path — VERIFIED. Every authentication decision routes throughIsAuthenticated:middleware.go:193,handlers/index.go:13,handlers/auth.go:14,handlers/handlers.go:167(getUserInfo, used by all template rendering including public pages),handlers/source_management.go:1197. The only direct value reads arehandlers/profile.go:156,167, which sit behindRequireAuthon/user/{username}and read identity, not authorization state — pre-existing pattern, correctly documented in place. No entry point skips the check.7. Config — minimal, fail-loud, VERIFIED. Exactly one constant, one
Configfield, oneenvDurationcall plus its struct-literal assignment;envDurationreturns an error on set-but-unparseable, whichNewpropagates so fx aborts startup.TestSessionIdleTimeoutcovers unset/valid/unparseable. Nothing else in the package restructured — the #92 rebase stays mechanical.8. Test hygiene — VERIFIED, plus four mutations the author did not list:
expired→ caught (TestTouch_DoesNotExtendAbsoluteCap,TestIdleTimeoutDisabled_AbsoluteCapStillApplies).Touchalso rewritingCreatedAtKey→ caught on both assertions (item 1 above).Touch's bool contract so the middleware always saves → caught byTestTouch_LazyBelowRefreshThresholdplus the two false-return tests, which assert the contract directly.idleRefreshDivisor10 → 5 → NOT caught, full suite exits 0. See non-blocking item 1.Injected clock throughout, no
time.Sleepanywhere in the added tests,-raceclean.9. Repo policy — all clean.
.golangci.ymlunmodified, sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb. Dockerfile still pinned togolangci/golangci-lint:v2.12.2at its digest. Single commit, titleAdd inactivity-based session timeout (closes #66).TODO.mdupdated in the same commit.make fmt-checkgreen. No attribution trailers, no AI/vendor references anywhere in the diff, no 4-byte emoji, no non-inclusive terminology. Naming is stutter-free and idiomatic with the surrounding code. No scope creep.Definition of done from the issue and all five items of the implementation-requirements comment are satisfied.
Non-blocking findings
The documented 10% bound is not pinned by any test (
internal/session/session.go:69). ChangingidleRefreshDivisorfrom10to5passes the entire suite — verified by execution. The two tests that touch the band (TestTouch_LazyBelowRefreshThresholdadvances 1s;TestTouch_RefreshesIdleDeadlineadvances half the window) straddle it too loosely to constrain the value. The README and PR body both make a specific numeric promise ("up to 10% early", "at most ten times per idle window") that nothing enforces. Not a correctness or security defect — every divisor > 1 preserves the "never late" guarantee and only shifts the earliness bound — but a test asserting no rewrite just belowidleTimeout/10and a rewrite just above would make the promise real. Acceptable to land as-is.The codec/Options skew itself is still there (
internal/session/session.go:145-154). This PR correctly closes the hole with a stricter server-side check, but the underlying cause — assigningstore.Optionsinstead of callingstore.MaxAge(...)— remains, soCodecsstill carry a 30-day bound that no longer matches anything. Deliberately out of scope here; worth a follow-up issue so the defence-in-depth layer agrees with the enforced policy.internal/config/config_test.go:174renamestestRetentionSweepIntervalErrortoexpectStartupError. Correct call — the helper is now shared by two tests and the old name would be a lie — but it does touch the package #92 rewrites, adding a small extra hunk to that rebase beyond the "one constant, one field, one parse call" the spec asked for. Trivial to resolve; flagging only so it is not a surprise.internal/middleware/middleware.go:217reuses the outererrfroms.session.Get(r)rather than scoping it in theif. Harmless, lint-clean, butif err := s.session.Save(...); err != nilmatches the surrounding idiom more closely.Test-helper nits.
testSessionWithClockandtestMiddlewareWithSessionClockboth return the*fakeClockthey were handed, which the callers already own.fakeClockis duplicated acrossinternal/sessionandinternal/middlewaretests — unavoidable across packages, just noting it.README could mention the one-time forced re-login. The fail-closed treatment of pre-change cookies is explained in the PR body but not in the operator-facing docs; a sentence in the sessions section would save a support question on deploy. Also,
SESSION_IDLE_TIMEOUTis documented as "set it to0to disable" while the code disables on any non-positive value — immaterial.Verdict
PASS — no blocking findings. The security-critical property (activity refreshes the idle clock and only the idle clock, and only for authenticated requests) is correctly implemented, guarded at two layers, and genuinely covered by tests that I confirmed fail under the exact bug the issue warns about. CI is green on the head commit and the branch fast-forwards onto
main.Manager note
Independent review verdict: PASS, no blocking findings. The reviewer did not author this change.
This PR closes a real pre-existing hole, and that is now confirmed
The PR body claimed the 7-day absolute session cap was previously enforced only by the browser. I asked the reviewer to verify that by execution rather than accept it, because it is the kind of claim that is easy to assert and consequential if wrong in either direction. It is true:
gorilla/sessions@v1.4.0NewCookieStoresets the codec max-age to 30 days viacs.MaxAge(...), and assigningstore.Optionswholesale (internal/session/session.go:148) never touchesCodecs. A throwaway probe confirmed the app's pattern still decodes a cookie past itsOptions.MaxAge, whilestore.MaxAge(1)correctly rejects it.So on
maintoday, a retained session cookie would be honoured by the server for up to 30 days, not 7. The cap existed only as a browser hint. This PR closes that as a side effect of adding server-side expiry checks. @sneak — that is worth knowing independently of the idle-timeout feature; it is a security fix hiding inside a hardening ticket. The residual codec/Optionsskew is tracked as #108.The test quality question I flagged
The author disclosed that their first draft of
TestTouch_DoesNotExtendAbsoluteCapjumped a full 7 days at the end, so it expired under the mutation anyway and only a white-box anchor assertion caught the bug — and that they rewrote it. I asked the reviewer to check the rewrite genuinely bites and is not off-by-one fragile. Both confirmed by execution:sess.Values[CreatedAtKey] = now.Unix()toTouch— the exact bug the issue warned about — fails the test on both the behavioural and anchor assertions.step = 30m,steps = int(168h/30m) - 1 = 335, the loop reaches167h30m, the final step lands on exactly168h0m0s, andexpireduses!now.Before(deadline)so on-the-cap is dead. No rounding window, so no flake risk.An author catching a weakness in their own test and fixing it before review is the behaviour I want; it is also why I asked for it to be re-verified rather than taken on trust.
Other execution-verified points
Touch(only call site ismiddleware.go:216, after the auth guard), andTouchre-guards itself. So the property does not depend on the call site staying correct.lastSeenand bounded by 10% of the configured window, never late.make testat 4.5s with the Go test cache bypassed (GOFLAGS=-count=1), 252 subtests — comfortably inside the 20s policy and better than the author's 11.2s claim.Tooling provenance
The reviewer's first
make checkwas VOID: 24 issues, every path prefixed../wt-issue-66-verify/, a worktree outside its own that had already been deleted, with cache-read failures for non-existent files. It discarded the run, rangolangci-lint cache clean, and got a valid green. Itsscript/cibuildwas 0.30s with all 22 layersCACHEDand was correctly not counted; the real evidence is Gitea run 107 onb04bc2c, success in 3m6s.That is the third independent sighting of the relative-path contamination form today, all from deleted worktrees. #106 has the details.
Non-blocking
Six items, tracked as #108. The one worth naming: changing
idleRefreshDivisorfrom 10 to 5 leaves the suite green, so the documented "up to 10% early, never late" bound is prose rather than a pinned guarantee.Labeled
merge-readyand assigned to @sneak.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.