fix(types): use a struct shadow so Field decodes under encoding/json/v2 #6

Merged
thklein merged 1 commit from fix/json-v2-field into main 2026-09-06 19:20:09 +00:00
Owner

What

(*Field).UnmarshalJSON in pkg/types/prompt.go re-entered json.Unmarshal through a defined pointer type to strip its own method:

type field *Field
...
return json.Unmarshal(b, field(f))

encoding/json/v2 resolves a marshaler method through a defined pointer type's element as well, so that conversion no longer de-methods anything: UnmarshalJSON calls itself for every JSON object until the process dies of fatal error: stack overflow.

Switched to a defined struct shadow, which has no methods of its own under either implementation:

type field Field
...
return json.Unmarshal(b, (*field)(f))

Why it matters

Go 1.27 makes encoding/json/v2 the default. BasePeak.AI's image build installs Go from the Wolfi feed unpinned, so published images have been built on 1.27 while go.mod (go 1.26.3 / toolchain go1.26.5), make test and CI all ran v1 — a toolchain directive only forces toolchain upgrades, so it could not hold the build down.

Result: images that passed lint, tests and /api/version crashed at runtime the moment an agent decoded a prompt Field from an object. Tenant fond-dolphin-58 was down for roughly 20 hours across two releases on green builds. bpai 0.87.1 papered over it with ENV GOEXPERIMENT=nojsonv2; this PR is what lets that workaround be removed.

Scope

type field *Field was the only instance of the de-methoding-via-pointer idiom in the repo (rg '^\s*type \w+ \*\w+'). The other three custom unmarshalers were checked and are safe:

  • Fields.UnmarshalJSON re-enters on []Field, an unnamed slice type, which carries no methods.
  • config.AuthConfig already uses a struct shadow (type AuthConfig types.AuthConfig).
  • engine.Context.UnmarshalJSON panics by design.

Verification

New tests drive the types through json.Unmarshal rather than calling UnmarshalJSON directly — the existing tests call the method directly, which skips the re-entry that actually regressed.

go test ./pkg/types/   # GOTOOLCHAIN=go1.26.5   ok
go test ./pkg/types/   # bare go1.27.1 (json/v2) ok

Standalone repro (decode {"name":"request"} into a types.Field): stack overflow on bare 1.27.1 before, clean under both after.

Full ./pkg/... suite run under both toolchains: identical results, the only failures being the two pre-existing network-dependent pkg/loader/github tests, which fail the same way on main.

## What `(*Field).UnmarshalJSON` in `pkg/types/prompt.go` re-entered `json.Unmarshal` through a defined **pointer** type to strip its own method: ```go type field *Field ... return json.Unmarshal(b, field(f)) ``` `encoding/json/v2` resolves a marshaler method through a defined pointer type's *element* as well, so that conversion no longer de-methods anything: `UnmarshalJSON` calls itself for every JSON object until the process dies of `fatal error: stack overflow`. Switched to a defined **struct** shadow, which has no methods of its own under either implementation: ```go type field Field ... return json.Unmarshal(b, (*field)(f)) ``` ## Why it matters Go 1.27 makes `encoding/json/v2` the default. BasePeak.AI's image build installs Go from the Wolfi feed unpinned, so published images have been built on 1.27 while `go.mod` (`go 1.26.3` / `toolchain go1.26.5`), `make test` and CI all ran v1 — a `toolchain` directive only forces toolchain *upgrades*, so it could not hold the build down. Result: images that passed lint, tests and `/api/version` crashed at runtime the moment an agent decoded a prompt `Field` from an object. Tenant `fond-dolphin-58` was down for roughly 20 hours across two releases on green builds. bpai 0.87.1 papered over it with `ENV GOEXPERIMENT=nojsonv2`; **this PR is what lets that workaround be removed.** ## Scope `type field *Field` was the only instance of the de-methoding-via-pointer idiom in the repo (`rg '^\s*type \w+ \*\w+'`). The other three custom unmarshalers were checked and are safe: - `Fields.UnmarshalJSON` re-enters on `[]Field`, an *unnamed* slice type, which carries no methods. - `config.AuthConfig` already uses a struct shadow (`type AuthConfig types.AuthConfig`). - `engine.Context.UnmarshalJSON` panics by design. ## Verification New tests drive the types through `json.Unmarshal` rather than calling `UnmarshalJSON` directly — the existing tests call the method directly, which skips the re-entry that actually regressed. ``` go test ./pkg/types/ # GOTOOLCHAIN=go1.26.5 ok go test ./pkg/types/ # bare go1.27.1 (json/v2) ok ``` Standalone repro (decode `{"name":"request"}` into a `types.Field`): stack overflow on bare 1.27.1 before, clean under both after. Full `./pkg/...` suite run under both toolchains: identical results, the only failures being the two pre-existing network-dependent `pkg/loader/github` tests, which fail the same way on `main`.
fix(types): use a struct shadow so Field decodes under encoding/json/v2
Some checks failed
Request reviewers for Dependabot PRs / request_reviewers (pull_request_target) Has been skipped
smoke / check-label (pull_request_target) Failing after 1s
smoke / gpt-4o-2024-08-06 (pull_request_target) Has been skipped
smoke / gpt-4o-mini-2024-07-18 (pull_request_target) Has been skipped
smoke / claude-3-7-sonnet-20250219 (pull_request_target) Has been skipped
Validate docs build / validate-docs (pull_request) Failing after 25s
integration / integration (ubuntu-22.04) (pull_request) Failing after 30s
test / test (ubuntu-22.04) (pull_request) Failing after 30s
test / test (windows-latest) (pull_request) Has been cancelled
integration / integration (windows-latest) (pull_request) Has been cancelled
c6924974a2
(*Field).UnmarshalJSON re-entered json.Unmarshal through `type field
*Field` to strip its own method. encoding/json/v2 resolves a method
through a defined pointer type's element as well, so the conversion no
longer de-methods anything and the function called itself until the
process died of `fatal error: stack overflow` — whenever a Field decoded
from a JSON object, which is the common case.

A defined struct type (`type field Field`) has no methods of its own
under either implementation, so it strips the method the way the pointer
shadow was assumed to.

This shipped: Go 1.27 makes json/v2 the default, images built on it
crashed at runtime while every test stayed green on the 1.26 toolchain
go.mod selects, and a tenant was down for a day on a green build.

Tests drive the types through json.Unmarshal rather than calling
UnmarshalJSON directly, since it is the re-entry and not the method body
that regressed; they pass under both go1.26.5 and bare go1.27.1.
thklein deleted branch fix/json-v2-field 2026-09-06 19:20:09 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
basepeak-ai/gptscript!6
No description provided.