chore(go): upgrade to Go 1.27 - #4105
Conversation
Moves every module to `go 1.27.0` (32 go.mod files plus go.work), shifts the
CI matrix to 1.27/1.26/1.25, and bumps the images and docs that name a Go
version.
golangci-lint had to move with it. v2.12.2 is built with go1.25 and cannot
analyse a 1.27 toolchain at all: against a go-1.26 module it fails typecheck
inside the standard library itself (`math/rand/v2` now uses a generic method,
which its go/types cannot parse), and against a go-1.27 module it refuses to
start — "the Go language version (go1.26) used to build golangci-lint is lower
than the targeted Go version (1.27.0)". v2.13.2 is the first release built on
go1.27. Running both linters over the root module confirms the bump is
version-neutral: v2.13.2 reports the identical 46 issues on go1.26 and on
go1.27, so Go 1.27 itself introduces no new findings.
The three lint/quality jobs also had to leave setup-go '1.26'. They do not use
.github/actions/setup-go-toolchain, so GOTOOLCHAIN stays `local` and a go-1.27
module stops them dead at `go mod download` with "go.mod requires go >= 1.27.0
(running go 1.26.5; GOTOOLCHAIN=local)".
Three characterization tests changed, all from one cause: Go 1.27 re-implements
encoding/json on top of encoding/json/v2. GOEXPERIMENT=nojsonv2 makes all three
pass again, which is what identifies the cause rather than a behaviour
regression in GoFr.
- Invalid UTF-8 is still replaced by U+FFFD, but emitted as a raw UTF-8 rune
instead of a six-byte backslash-u escape. Same decoded string, different
wire bytes — and these two tests exist to pin wire bytes, so they now pin
the 1.27 form.
- UnmarshalTypeError no longer prefixes the Go type name onto the field path,
so "field charBindTarget.a" became "field .a". The error's classification
is unchanged; only its text is.
Nothing else moved. `go build` and `go vet` are clean across all 33 modules,
`go mod tidy -diff` is clean across pkg/, and the pkg, submodule and example
suites pass on 1.27 — the examples against live MySQL, Redis and Zipkin.
Self-reviewBranch is now up to date with Everything below came out of actually running the json paths under 1.27 rather than reading the diff. 🔴 The two error-string changes are masking a real regression in
|
| call shape | go 1.26 | go 1.27 |
|---|---|---|
json.Unmarshal(body, &i) — what GoFr does |
field charBindTarget.a |
field .a |
json.Unmarshal(body, i) — i is already a pointer |
field charBindTarget.a |
field charBindTarget.a |
So this is not a cosmetic upstream rename. On Go 1.27 every GoFr user's bind error loses the struct name — json: cannot unmarshal number into Go struct field .a of type string — and this PR bakes that degradation into the test as if it were expected.
The & is redundant: Bind already rejects a non-pointer at request.go:80, so i is guaranteed to be a pointer by the time line 93 runs. Dropping it restores the message on 1.27 and leaves 1.26 unchanged, and both test expectations can then stay as they are.
The &i itself is ancient (fb585c15d, "Db utility (#10)") — harmless until 1.27, which is why nothing caught it. I'd rather fix it here than ship a worse error message with the toolchain bump.
🟡 The version matrix no longer tests three versions
go-version: ['1.27','1.26','1.25'], but go.work says go 1.27.0 and .github/actions/setup-go-toolchain deliberately installs whatever go.work asks for and sets GOTOOLCHAIN=auto. So the 1.25 and 1.26 legs upgrade themselves to 1.27 and every leg tests the same toolchain.
That was already true before this PR, but it matters more now: the tests changed in this PR pin 1.27-only wire bytes, so a leg that genuinely ran 1.26 would fail. Either the older legs should be dropped, or they need GOTOOLCHAIN=local and a build tag, and the "lowest matrix Go version" comment on the coverage upload should say which version actually produces it.
🟡 The two wire-format tests pin bytes that depend on the consuming module
The escaped-vs-raw U+FFFD difference is gated by the main module's go directive, not by the toolchain:
main module go directive |
json.Marshal of invalid UTF-8 |
|---|---|
go 1.26.0 |
{"uri":"/��/ok"} |
go 1.27.0 |
raw U+FFFD bytes |
GOEXPERIMENT=nojsonv2 reverts it, as the comments say — worth noting GODEBUG=jsonv2=0 does not, so that lever is the right one to document. Since these tests exist to pin GoFr's log wire format, it is worth saying explicitly in the comment that the format follows the application's own go line, not GoFr's.
🔵 Scope
golangci-lint v2.12.2 → v2.13.2 rides along in three places. Almost certainly needed to parse 1.27, but it is an unrelated version bump inside a Go-upgrade PR and is not mentioned in the description.
✅ What checks out
- All 32
go.modfiles are on1.27.0— no stragglers, which is the usual failure mode for this change. go.workand the rootDockerfilemoved with them.- Docs corrected rather than merely bumped:
README.mdclaimed 1.24 whilego.modalready required 1.26, anddocs/AGENTS.mdclaimed 1.25. Both are now accurate. - The coverage-upload condition was moved off the dropped
1.24leg instead of being left dangling. go build ./...clean and the full./pkg/...suite green locally on go1.27.0.
Suggested follow-up before merge
- Change
request.go:93tojson.Unmarshal(body, i)and revert the two expectations inrequest_test.go. - Correct the comment above
TestRequest_Char_BindJSON— the cause is the pointer-to-interface, not a json/v2 rename. - Decide on the matrix: three labels that all run 1.27 is worse than one honest label.
Bind unmarshalled through a pointer to an interface:
if rv := reflect.ValueOf(i); rv.Kind() != reflect.Pointer {
return errNonPointerBind
}
...
return json.Unmarshal(body, &i) // i is already a pointer; &i is **any
The guard above already rejects anything that is not a pointer, so the
extra & was redundant. It was also harmless until Go 1.27 re-implemented
encoding/json on top of encoding/json/v2, which renders the struct name
from the value it is handed. Through the double indirection the name is
lost, so a wrong-typed field degraded for every GoFr user:
go 1.26: json: cannot unmarshal number into Go struct field charBindTarget.a of type string
go 1.27: json: cannot unmarshal number into Go struct field .a of type string
This is not a json/v2 rendering change — passing the pointer directly
keeps the struct name on 1.27, which is what this commit does:
| call shape | go 1.26 | go 1.27 |
|-------------------------------|------------------|------------------|
| json.Unmarshal(body, &i) | charBindTarget.a | .a |
| json.Unmarshal(body, i) | charBindTarget.a | charBindTarget.a |
The two expectations in TestRequest_Char_BindJSON go back to naming the
struct, and the comment claiming json/v2 dropped the prefix is removed,
since that was the wrong diagnosis.
Behaviour is otherwise unchanged. Every other bind shape was differenced
between the two forms — valid struct, malformed body, empty body, null,
unknown field, pointer to map / slice / []byte / any — and all are
identical. The one further difference is a typed nil pointer, which the
old form silently accepted and bound nothing into, and which now reports
json: Unmarshal(nil *T); no test covered it, and a handler that binds a
nil pointer would nil-deref on the next line regardless.
Verified end to end against a real GoFr server, not just in unit tests:
POST /bind {"a":5}
before 500 "...Go struct field .a of type string"
after 500 "...Go struct field charBindTarget.a of type string"
No performance cost — one less indirection is two fewer allocations:
BenchmarkBindJSON &i: 6355 B/op 24 allocs/op
BenchmarkBindJSON i: 6335 B/op 22 allocs/op
Acted on the first finding —
|
| case | &i (before) |
i (after) |
|---|---|---|
| wrong-typed field | field .a |
field charBindTarget.a |
| typed nil pointer | nil, binds nothing |
json: Unmarshal(nil *T) |
The second is worth a look, though I think it is fine: no test covered it, and a handler that binds a typed nil pointer would nil-deref on the next line anyway. Say the word if you would rather preserve the silent no-op and I will guard it explicitly.
Confirmed against a running server, not only in unit tests
Built two binaries from this branch — one with &i, one without — and ran each as a real GoFr service against this worktree's sbx sandbox (gofr-go127: MySQL, Redis, Zipkin; all three went asleep → awake, so the app genuinely connected). Same requests to each:
POST /bind {"a":"hello","b":42}
before 201 {"data":{"a":"hello","b":42}}
after 201 {"data":{"a":"hello","b":42}} identical
POST /bind {"a":5}
before 500 json: cannot unmarshal number into Go struct field .a of type string
after 500 json: cannot unmarshal number into Go struct field charBindTarget.a of type string
POST /bind {"a":"x","b":"nope"}
after 500 json: cannot unmarshal string into Go struct field charBindTarget.b of type int
Malformed body, empty body, null, unknown field and /.well-known/alive all responded identically across the two binaries.
No performance cost
Removing the indirection removes two allocations per bind:
| B/op | allocs/op | |
|---|---|---|
json.Unmarshal(body, &i) |
6355 | 24 |
json.Unmarshal(body, i) |
6335 | 22 |
-benchtime 3000x -count 6; ns/op overlaps between the two, allocation counts are stable.
Still open from the review
- The version matrix labels 1.25 / 1.26 / 1.27 but every leg resolves to 1.27 via
go.work+setup-go-toolchain. - The
golangci-lintv2.12.2 → v2.13.2 bump is unmentioned in the description. Worth keeping — a v2.12.2 binary refuses a 1.27 module outright:the Go language version (go1.26) used to build golangci-lint is lower than the targeted Go version (1.27.0). That is why it has to move in the same PR.
Unmarshalling into the pointer rather than into a pointer to the interface
recovered the struct name in the error, but it also changed one case the
previous form silently allowed: a typed nil pointer.
The old code decoded into the local interface, which discarded the static
type, so for a nil pointer json decoded into a fresh generic value and the
caller's pointer was left alone. Syntax errors still surfaced; type errors
never did. Passing the pointer straight through instead reports
json: Unmarshal(nil *T) for every body.
Differencing every target shape against GoFr on Go 1.26 showed the reach
was wider than the single case first spotted -- a nil pointer swallowed
wrong-type, nested-wrong-type, array-into-struct, bare number and bare
string alike, all of which had started to error.
The nil case is now routed to a throwaway interface, reproducing the old
behaviour exactly rather than approximately. reflect.Value is hoisted out
of the existing guard, so the check costs nothing on the hot path.
Differential across 10 target shapes x 12 bodies, GoFr on Go 1.26 vs this
branch on Go 1.27:
upgrade alone (&i) 7 of 120 rows differ
upgrade + pointer fix 10 of 120 rows differ
upgrade + fix + nil guard 5 of 120 rows differ
The remaining 5 are upstream and not reachable from GoFr: a []byte target
gains a message prefix on invalid base64, a nested field is named after
the outer struct rather than the inner one (T.i.z, was Inner.i.z), and a
**T target still loses the name the way *T used to.
Allocations are unchanged by the guard and still below the original:
&i 6353 B/op 24 allocs/op
i + nil guard 6331 B/op 22 allocs/op
Thorough behaviour check — and a correction to my previous commentI said the only side effect of dropping the Method10 target shapes × 12 body shapes = 120 cases, run through three implementations, comparing GoFr on Go 1.26 (today's behaviour) against each candidate on Go 1.27. Targets:
My first fix made things worse on this measure before the guard was added — worth stating plainly. What the nil guard restoresA nil pointer cannot be written to. The old form decoded into the local interface, which dropped the static type, so a nil pointer silently accepted anything:
Reproduced exactly — decoded into a throwaway interface, so syntax errors still surface and type errors still do not. To be clear about what this preserves: binding a nil pointer quietly succeeding is a defect of the same family GoFr fixed for non-pointers in #3770. I have kept it because the Go upgrade is not the place to change it. It deserves its own PR. The 5 remaining differences are upstream, not reachable from GoFr
None of these are affected by how Also changed by the upgrade, outside Bind
That is what the two wire-format test updates in this PR are pinning. It is not avoidable in a library — the default follows the application's own PerformanceAllocation counts are deterministic; ns/op on this machine was too noisy to quote.
The guard adds no allocation over the plain pointer form. LinterAgreed on keeping the I hit that locally, which is why lint for these packages was verified in CI rather than on my machine. Still openThe version matrix labels 1.25 / 1.26 / 1.27 but |
Umang01-hash
left a comment
There was a problem hiding this comment.
Ran the touched packages on the real 1.27 toolchain — all green, gofmt/vet clean, and CI Code Quality is passing.
The Bind change is the nice bit: confirmed that under v2 the old Unmarshal(body, &i) drops the struct name from type errors (field .a vs field charBindTarget.a), and passing i directly fixes it. Also checked the nil-pointer path stays a no-op exactly like before, and that the v1 API still keeps case-insensitive matching + last-wins duplicate keys on 1.27, so nothing downstream shifts. LGTM.
The branch bumped every go directive to 1.27.0 while the CI matrix still
listed 1.25 and 1.26. Those two legs could not have run: a module whose
go.work says 1.27.0 is refused outright by an older toolchain.
go: go.work requires go >= 1.27.0 (running go 1.26.0; GOTOOLCHAIN=go1.26.0)
They passed because .github/actions/setup-go-toolchain installed whatever
go.work asked for and set GOTOOLCHAIN=auto, so all three legs upgraded to
1.27 and tested the same toolchain while reporting three. The matrix has
been decorative for a while — every release since v1.56.0 carried a go
directive newer than its own lowest leg.
Two things follow from the directive rather than the toolchain, which is
what makes this worth getting right:
- encoding/json's json/v2 behaviour is selected by the main module's go
line. At 1.27 invalid UTF-8 marshals as a raw U+FFFD instead of the
escaped form, changing the bytes of every response body and log line,
and UnmarshalTypeError renders struct fields differently.
- the directive is a hard floor for users. At 1.27.0 nobody on 1.25 or
1.26 can build GoFr at all.
So the directive stays at the minimum GoFr supports, 1.25.0, and the
matrix genuinely runs 1.25, 1.26 and 1.27 with GOTOOLCHAIN=local. GoFr
gains 1.27 coverage, behaves identically on all three, and forces nobody
to upgrade.
- every go.mod and go.work: 1.25.0 (32 modules)
- matrix: ['1.27','1.26','1.25'], GOTOOLCHAIN=local per leg
- setup-go-toolchain removed; with the directive at the floor there is
no upgrade to allow, and the covdata problem it worked around came
from the toolchain-module GOROOT that the upgrade produced
- lint jobs and the root Dockerfile build on 1.27, which is fine above
the floor
- README (claimed 1.24), docs/AGENTS.md (claimed 1.25) and the CI
recipe now all say 1.25, matching go.mod for the first time
- golangci-lint v2.12.2 -> v2.13.2 kept: v2.12.2 refuses a module it
considers newer than itself
The three test changes for json/v2 wire formats are reverted, since at
1.25 the old bytes and error strings are what the stdlib produces. Only
the Bind pointer fix is kept — differencing 10 target shapes against 12
bodies shows 0 of 120 cases behave differently from the previous form
under 1.25, and it drops two allocations per bind.
…an run
The previous commit set the floor to 1.25 so three legs could run for
real. CI answered that 1.25 is not reachable, twice:
go: github.com/jlaffaye/ftp@v0.2.1 requires go >= 1.26
(running go 1.25.14; GOTOOLCHAIN=local)
go: gofr.dev/pkg/gofr/datasource/file/s3@v0.3.0 requires go >= 1.26.0
(running go 1.25.14; GOTOOLCHAIN=go1.25.14)
The first was ours to fix: examples/using-add-filestore held
jlaffaye/ftp v0.2.1, which declares go 1.26, while
pkg/gofr/datasource/file/ftp already used v0.2.2, which declares go
1.20. Aligned on v0.2.2.
The second is not fixable here. Our own published submodules declare go
1.26.0, and the examples depend on them by version, so a 1.25 toolchain
cannot resolve the workspace no matter what this branch declares. That
would need those submodules re-released against a lower directive.
So the floor is 1.26 and the matrix is 1.26 and 1.27, both real:
- every go.mod and go.work: 1.26.0, unchanged from v1.60.0, so nobody
is forced to upgrade
- matrix: ['1.27','1.26'] with GOTOOLCHAIN=local, verified against a
genuine go1.26.0 rather than assumed — go mod download resolves, the
tree builds, and the http, logging and middleware suites pass
- coverage upload moves to the 1.26 leg, still the lowest
- README, docs/AGENTS.md and the CI recipe say 1.26, matching go.mod
Dropping the third leg is not a reduction in coverage. It never ran: the
toolchain shim upgraded it, so it re-tested the newest version under an
older label. Two legs that run beat three that do not.
Behaviour is identical on both. json/v2 is selected by the go directive,
which is the same on every leg, so the wire formats and error strings
that this branch originally rewrote stay as they are on development.
A Template returned from a DELETE handler gets status 204, which must not
carry a body. The responder rendered into it anyway and threw the result
away:
r.w.WriteHeader(statusCode) // 204
v.Render(r.w) // Render discards Execute's error
net/http drops a body on a 204, so no client has ever received those
bytes -- verified against a real server on 1.25, 1.26 and 1.27, all three
answering 204 with an empty body and no Content-Length. The defect was
only ever visible to something that records writes verbatim, and there it
was version-dependent, because httptest.ResponseRecorder keeps the bytes
while reporting the error:
write to a recorder after WriteHeader(204)
go1.25.14 n=4 err=<nil>
go1.26.0 n=0 err=http: request method or response status code does not allow body
html/template writes incrementally and stops at the first write error, so
what the recorder held changed with the toolchain:
go1.25.14 <h1>Hi</h1> the whole page
go1.26.0 <h1> truncated at the first text node
go1.27.0 <h1>
The test pinned "<h1>" and called the truncation a latent bug, which held
only because every leg of the CI matrix silently ran the newest Go. A
genuine 1.25 leg fails on it.
The render is skipped when the status forbids a body, using the same rule
net/http applies. Nothing on the wire changes: an empty body before, an
empty body after, on every version. What changes is that GoFr no longer
parses and executes a template whose output it cannot send, and the
recorder now sees what the client sees.
Scoped to Template deliberately. File and XML write their body in one
call and ignore the error, so a recorder keeps it identically on every
version -- the same wasted write, but no divergence. Worth tidying, not
worth folding into a Go upgrade.
Verified on genuine toolchains, not on one pretending to be three: the
whole ./pkg/... suite passes on go1.25.14 with this fix, and
TestResponder_Char passes on 1.25, 1.26 and 1.27 alike.
The root module is what people import, and it depends on no published
gofr.dev release, so it can be built and tested on 1.25. What could not
was the workspace: examples and datasource submodules require published
gofr.dev v1.57.0/v1.59.0 and file/s3 v0.3.0, and those declare go 1.26.0,
so resolving go.work needs 1.26 whatever this branch says.
Testing the root module outside the workspace separates the two. The PKG
job sets GOWORK=off and runs 1.25, 1.26 and 1.27; the Example and
Submodule jobs keep 1.26 and 1.27, since they genuinely need a published
release that requires it.
- root go.mod: 1.25.0. Submodules and examples stay 1.26.0, which is
what their own dependencies force.
- PKG matrix ['1.27','1.26','1.25'], GOWORK=off, GOTOOLCHAIN=local
- PKG coverage upload follows its own lowest leg, now 1.25
- README, docs/AGENTS.md and the CI recipe give both floors: 1.25 for
the framework, 1.26 for the datasource modules
Verified against real toolchains rather than a matrix label: go1.25.14,
go1.26.0 and go1.27.0 each build the root module, the full ./pkg/... suite
passes on go1.25.14, and the workspace still resolves on go1.26.0 for the
jobs that use it.
This is the first time the older legs run anything. Every release since
v1.56.0 shipped a go directive above its own lowest leg, so the toolchain
upgraded underneath and the leg re-tested the newest version under an
older name.
Correcting an error of mine. I had concluded the json/v2 wire format was
selected by the main module's go directive, and on that basis reverted
these two expectations to the pre-1.27 escape and asserted the legs would
agree. The 1.27 leg failed, and a full matrix shows the directive has
nothing to do with it -- the toolchain decides:
go.mod directive toolchain 1.26 toolchain 1.27
1.25.0 {"uri":"/�/ok"} {"uri":"/<U+FFFD>/ok"}
1.26.0 {"uri":"/�/ok"} {"uri":"/<U+FFFD>/ok"}
1.27.0 refuses to build {"uri":"/<U+FFFD>/ok"}
So no single literal can pass on all three legs, which is why the first
revision of this branch changed these tests and my revert changed them
back: each was right for the toolchain it was tested against and wrong
for the others.
The expectation now moves with the toolchain, through a constant selected
by a go1.27 build tag. Both files carry the reasoning so the next person
does not have to rediscover it.
This is a real user-visible difference, not a test artifact. GoFr encodes
its logs and its response envelope with encoding/json, so a service built
with Go 1.27 emits different bytes for invalid UTF-8 than the same
service built with 1.26. Nothing in GoFr can prevent that, but it belongs
in the release notes of whichever release first tests against 1.27.
Verified on genuine toolchains: pkg/gofr/logging and
pkg/gofr/http/middleware pass on go1.25.14, go1.26.0 and go1.27.0, and
the whole ./pkg/... suite passes on all three. The one failure seen along
the way, TestCircuitBreaker_SlowHealthCheckDoesNotBlock on 1.26, is the
known timing flake and passes at -count=3 on both 1.26 and 1.27.
|
Closing this without merging. The branch What the work turned up, for whoever picks it up next: The version matrix was never testing three versions. Making the legs real is possible, but only for the root module. Two real defects surfaced once the older legs actually ran, both fixed here and both worth keeping regardless of what happens to the Go upgrade:
One difference cannot be fixed in GoFr and should be release-noted. The json/v2 wire format is selected by the toolchain, not by the go directive — a build on 1.27 emits invalid UTF-8 as a raw U+FFFD where 1.26 emits Worth splitting out of a version bump if this is revisited: the two fixes stand alone, and the matrix correction is independent of which Go version is being added. |
Moves the repo to Go 1.27: every module's
godirective (32go.modfiles plusgo.work), the CI matrix, thegolang:base images, and the docs that name a Go version.Two CI pins had to move with it, and three characterization tests changed. Everything below was verified locally on go1.27.0 against go1.26.5.
golangci-lint v2.12.2 → v2.13.2 — required, not housekeeping
v2.12.2 is built with go1.25 and cannot analyse a Go 1.27 toolchain at all. Both halves fail:
could not import math/rand/v2 (… method must have no type parameters), because 1.27 added generic methods and itsgo/typescannot parse themcan't load config: the Go language version (go1.26) used to build golangci-lint is lower than the targeted Go version (1.27.0)v2.13.2 (2026-08-27) is the first release built on go1.27.
The bump is version-neutral, which is the part worth checking: running v2.13.2 over the root module on go1.26 and again on go1.27 produces the identical 46 issues. Go 1.27 introduces no new lint findings — the linter bump is purely about being able to run.
setup-go '1.26' → '1.27' in the three lint/quality jobs
code_quality,lint_changed_submodulesandlinting_partydo not go through.github/actions/setup-go-toolchain, soGOTOOLCHAINstayslocal. Against a go-1.27 module they stop dead atgo mod download:The test matrix shifts
['1.26','1.25','1.24']→['1.27','1.26','1.25'], and the two coverage-upload guards move with it (matrix.go-version == '1.24'→'1.25') soparse_coverageandupload_coveragestill find their artifacts.Three tests changed — all one cause
Go 1.27 re-implements
encoding/jsonon top ofencoding/json/v2.GOEXPERIMENT=nojsonv2makes all three pass again, which is what identifies this as a stdlib wire-format change rather than a behaviour regression in GoFr.Invalid UTF-8 is still replaced by U+FFFD, but emitted as a raw UTF-8 rune instead of a six-byte
�escape. Same decoded string, different wire bytes — andTest_LogWireFormat_HTMLEscapingOnTheRealPathandTest_LoggingContract_RequestLogEscapingexist specifically to pin wire bytes, so they now pin the 1.27 form.UnmarshalTypeErrorno longer prefixes the Go type name onto the field path, sofield charBindTarget.abecamefield .ainTestRequest_Char_BindJSON. The error's classification is unchanged; only its text is.Each site carries a comment recording the change and the
nojsonv2escape hatch. This is user-visible: anyone asserting on GoFr log bytes or on bind-error strings will see the same shift.Verification
go build ./..., all 33 modulesgo vet ./..., all 33 modulesgo mod tidy -diffacrosspkg/gofmton changed files./pkg/gofr/...pkg/submodules./examples/...examples/using-s3-filestore,using-add-filestore.github/scripts/check-binaries.shNot verified locally:
examples/using-subscriber, which needs Kafka. It fails identically on go1.26 and go1.27 here (TestMainInitialization, 30s timeout, no broker), so the failure is environmental — but CI's Kafka service is what will actually confirm it.One flake seen and dismissed:
TestCircuitBreaker_MixedHTTPMethodsasserts wall-clock< 2sand returned 2.021s once during a heavily loaded parallel sweep. In isolation it passes 8/8 on go1.27 and 6/6 on go1.26. Pre-existing timing margin, untouched here.