all: fix JSON serialization under Go 1.27's finalized encoding/json/v2

Go 1.27 enables GOEXPERIMENT=jsonv2 by default: encoding/json is now
backed by the json/v2 machinery, and github.com/go-json-experiment/json
compiles as a thin alias of the standard library's encoding/json/v2.
Several tag options and behaviors we relied on did not make the cut for
the final Go 1.27 API, breaking tailscaled at runtime and four packages'
tests. This change adapts to the final API while keeping the wire format
byte-for-byte identical on all Go versions.

First, the `format` tag option was demoted to experimental. Its mere
presence in a struct tag now makes marshaling and unmarshaling fail at
runtime. tailcfg.SSHAction.SessionDuration had `format:nano` (added in
a2dc517d7 to pin the v1 representation), so on Go 1.27 any netmap
containing an SSH policy failed to decode, breaking every PollNetMap.
Remove the option here and in net/speedtest; time.Duration still
marshals as int64 nanoseconds under encoding/json on all Go versions
(Go 1.27's v1 mode sets FormatDurationAsNano by default), so old
clients and servers are unaffected. Add a regression test locking in
the exact wire format.

Consequently, invert the cmd/vet jsontags rule: it previously required
an explicit `format` tag on time.Duration fields, which is now exactly
wrong. It now rejects any `format` tag option, which would have caught
this bug in CI.

Second, the `inline` tag option was renamed to `embed`. The standard
library silently ignores `inline`, while the pinned go-json-experiment
module (used on Go 1.26) only knows `inline`. Specify both options in
types/prefs and logtail; each implementation ignores the option it does
not know, producing identical output. Drop `inline` once we require
Go 1.27.

Third, encoding/json (v1) now dispatches to MarshalJSONTo and
UnmarshalJSONFrom methods and its v1 options flow into nested
jsonv2.MarshalEncode calls. Types whose v1 methods deliberately
routed through jsonv2 for v2 semantics (types/opt.Value, the
types/prefs preference types) would silently change wire format
(e.g. nil slices becoming null). Pin jsonv2.DefaultOptionsV2 in
their jsonv2 methods so the representation is the same regardless
of the entry point.

Finally, json.Marshal costs one more allocation under Go 1.27,
tripping the types/logger.AsJSON alloc test. Switch its fmt.Formatter
to jsonv2.MarshalWrite with explicit v1 options, which writes directly
to the fmt.State: one allocation on both toolchains with unchanged
output. Depaware files pick up the go-json-experiment/json/v1 options
shim as a new dependency of types/logger.

With this change, go test ./... passes with both Go 1.26.5 and
go1.27rc2.

Updates #20220
Fixes #20528
Fixes #20254

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I694c7d57fd81e55a579c579e9be10032bca569d4
This commit is contained in:
Brad Fitzpatrick
2026-07-19 09:58:50 -07:00
committed by Brad Fitzpatrick
parent ece1b12ebf
commit 82cfea90ca
19 changed files with 100 additions and 46 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck+
💣 github.com/go4org/hashtriemap from tailscale.com/derp/derpserver 💣 github.com/go4org/hashtriemap from tailscale.com/derp/derpserver
github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/hdevalence/ed25519consensus from tailscale.com/tka github.com/hdevalence/ed25519consensus from tailscale.com/tka
+1 -1
View File
@@ -41,7 +41,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+ github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+
github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+ github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+
github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck+
github.com/go-logr/logr from github.com/go-logr/logr/slogr+ github.com/go-logr/logr from github.com/go-logr/logr/slogr+
github.com/go-logr/logr/slogr from github.com/go-logr/zapr github.com/go-logr/logr/slogr from github.com/go-logr/zapr
github.com/go-logr/zapr from sigs.k8s.io/controller-runtime/pkg/log/zap+ github.com/go-logr/zapr from sigs.k8s.io/controller-runtime/pkg/log/zap+
+1
View File
@@ -9,6 +9,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/v1 from tailscale.com/types/logger
github.com/munnerz/goautoneg from github.com/prometheus/common/expfmt github.com/munnerz/goautoneg from github.com/prometheus/common/expfmt
💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz 💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz
github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus
+1 -1
View File
@@ -125,7 +125,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck+
L 💣 github.com/godbus/dbus/v5 from fyne.io/systray+ L 💣 github.com/godbus/dbus/v5 from fyne.io/systray+
L github.com/godbus/dbus/v5/introspect from fyne.io/systray+ L github.com/godbus/dbus/v5/introspect from fyne.io/systray+
L github.com/godbus/dbus/v5/prop from fyne.io/systray L github.com/godbus/dbus/v5/prop from fyne.io/systray
+1
View File
@@ -15,6 +15,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/v1 from tailscale.com/types/logger
github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/golang/groupcache/lru from tailscale.com/net/dnscache
💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
+1
View File
@@ -15,6 +15,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/v1 from tailscale.com/types/logger
github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/golang/groupcache/lru from tailscale.com/net/dnscache
💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon 💣 github.com/jsimonetti/rtnetlink from tailscale.com/net/netmon
github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink github.com/jsimonetti/rtnetlink/internal/unix from github.com/jsimonetti/rtnetlink
+1 -1
View File
@@ -30,7 +30,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck+
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns
github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/transport/tcp github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/transport/tcp
+10 -25
View File
@@ -38,7 +38,7 @@ func run(pass *analysis.Pass) (any, error) {
if tag == "" { if tag == "" {
continue continue
} }
var seenName, hasFormat bool var seenName bool
for opt := range strings.SplitSeq(tag, ",") { for opt := range strings.SplitSeq(tag, ",") {
if !seenName { if !seenName {
seenName = true seenName = true
@@ -116,12 +116,17 @@ func run(pass *analysis.Pass) (any, error) {
report(pass, structType, fieldVar, StringOnNonNumericKind) report(pass, structType, fieldVar, StringOnNonNumericKind)
} }
default: default:
key, _, ok := strings.Cut(opt, ":") // The `format` tag option did not make the cut for
hasFormat = key == "format" && ok // Go 1.27's encoding/json/v2. Its mere presence in a
// struct tag makes both v1 and v2 marshaling fail at
// runtime unless the caller passes the
// jsonv2.ExperimentalSupportFormatTag option.
// See https://go.dev/issue/71631 and
// https://github.com/tailscale/tailscale/issues/20528.
if key, _, ok := strings.Cut(opt, ":"); ok && key == "format" {
report(pass, structType, fieldVar, FormatUnsupported)
} }
} }
if !hasFormat && isTimeDuration(mayPointerElem(fieldVar.Type())) {
report(pass, structType, fieldVar, FormatMissingOnTimeDuration)
} }
} }
}) })
@@ -152,26 +157,6 @@ func isString(t types.Type) bool {
return ok && basic.Kind() == types.String return ok && basic.Kind() == types.String
} }
// isTimeDuration reports whether t is a time.Duration type.
func isTimeDuration(t types.Type) bool {
return isNamed(t, "time", "Duration")
}
// mayPointerElem returns the pointed-at type if t is a pointer,
// otherwise it returns t as-is.
func mayPointerElem(t types.Type) types.Type {
if pointer, ok := t.(*types.Pointer); ok {
return pointer.Elem()
}
return t
}
// isNamed reports t is a named typed of the given path and name.
func isNamed(t types.Type, path, name string) bool {
gotPath, gotName := typeName(t)
return gotPath == path && gotName == name
}
// typeName reports the pkgPath and name of the type. // typeName reports the pkgPath and name of the type.
// It recursively follows type aliases to get the underlying named type. // It recursively follows type aliases to get the underlying named type.
func typeName(t types.Type) (pkgPath, name string) { func typeName(t types.Type) (pkgPath, name string) {
+3 -3
View File
@@ -74,7 +74,7 @@ const (
OmitEmptyShouldBeOmitZero ReportKind = "OmitEmptyShouldBeOmitZero" OmitEmptyShouldBeOmitZero ReportKind = "OmitEmptyShouldBeOmitZero"
OmitEmptyShouldBeOmitZeroButHasIsZero ReportKind = "OmitEmptyShouldBeOmitZeroButHasIsZero" OmitEmptyShouldBeOmitZeroButHasIsZero ReportKind = "OmitEmptyShouldBeOmitZeroButHasIsZero"
StringOnNonNumericKind ReportKind = "StringOnNonNumericKind" StringOnNonNumericKind ReportKind = "StringOnNonNumericKind"
FormatMissingOnTimeDuration ReportKind = "FormatMissingOnTimeDuration" FormatUnsupported ReportKind = "FormatUnsupported"
) )
func (k ReportKind) message() string { func (k ReportKind) message() string {
@@ -89,8 +89,8 @@ func (k ReportKind) message() string {
return "should probably use `omitzero` instead of `omitempty`" return "should probably use `omitzero` instead of `omitempty`"
case StringOnNonNumericKind: case StringOnNonNumericKind:
return "must not use `string` on non-numeric types" return "must not use `string` on non-numeric types"
case FormatMissingOnTimeDuration: case FormatUnsupported:
return "must use an explicit `format` tag (e.g., `format:nano`) on a time.Duration type; see https://go.dev/issue/71631" return "must not use the `format` tag option; Go 1.27's encoding/json rejects it at runtime (see tailscale/tailscale#20528)"
default: default:
return string(k) return string(k)
} }
+8 -1
View File
@@ -186,7 +186,14 @@ type Logtail struct {
// differently-shaped payloads in a single upload. // differently-shaped payloads in a single upload.
type LogEntry[T any] struct { type LogEntry[T any] struct {
Logtail Logtail `json:"logtail,omitzero"` Logtail Logtail `json:"logtail,omitzero"`
Value T `json:",inline"` // The `inline` tag option was renamed to `embed` in Go 1.27's
// encoding/json/v2, but the pinned go-json-experiment module
// (used on older Go versions) only knows `inline`. Each
// implementation ignores the option it doesn't know, so specify
// both until we require Go 1.27 and drop `inline`.
//
//lint:ignore SA5008 staticcheck doesn't know Go 1.27's `embed` option yet
Value T `json:",inline,embed"`
} }
// UploadLogs uploads entries to the log server described by conf and returns // UploadLogs uploads entries to the log server described by conf and returns
+1 -1
View File
@@ -28,7 +28,7 @@ type Logtail struct {
type LogEntry[T any] struct { type LogEntry[T any] struct {
Logtail Logtail `json:"logtail,omitzero"` Logtail Logtail `json:"logtail,omitzero"`
Value T `json:",inline"` Value T `json:",inline,embed"` // both options; see the non-omit variant in logtail.go
} }
func UploadLogs[T any](ctx context.Context, conf Config, entries iter.Seq[LogEntry[T]]) error { func UploadLogs[T any](ctx context.Context, conf Config, entries iter.Seq[LogEntry[T]]) error {
+1 -1
View File
@@ -24,7 +24,7 @@ const (
// conduct the test. // conduct the test.
type config struct { type config struct {
Version int `json:"version"` Version int `json:"version"`
TestDuration time.Duration `json:"time,format:nano"` TestDuration time.Duration `json:"time"` // int64 nanoseconds; no jsonv2 format tag (tailscale/tailscale#20528)
Direction Direction `json:"direction"` Direction Direction `json:"direction"`
} }
+6 -1
View File
@@ -3088,7 +3088,12 @@ type SSHAction struct {
// SessionDuration, if non-zero, is how long the session can stay open // SessionDuration, if non-zero, is how long the session can stay open
// before being forcefully terminated. // before being forcefully terminated.
SessionDuration time.Duration `json:"sessionDuration,omitempty,format:nano"` // It is encoded as an int64 of nanoseconds (Go's time.Duration
// wire format for encoding/json v1). It must not use a jsonv2
// format tag; the mere presence of one makes Go 1.27's
// encoding/json fail to decode the struct. See
// https://github.com/tailscale/tailscale/issues/20528.
SessionDuration time.Duration `json:"sessionDuration,omitempty"`
// AllowAgentForwarding, if true, allows accepted connections to forward // AllowAgentForwarding, if true, allows accepted connections to forward
// the ssh agent if requested. // the ssh agent if requested.
+27
View File
@@ -1310,3 +1310,30 @@ func TestServiceActionTypeValid(t *testing.T) {
} }
} }
} }
// TestSSHActionJSON verifies that SSHAction round-trips through
// encoding/json with SessionDuration encoded as int64 nanoseconds.
// It notably guards against jsonv2 `format` tag options in struct
// tags, which Go 1.27's encoding/json rejects at runtime.
// See https://github.com/tailscale/tailscale/issues/20528.
func TestSSHActionJSON(t *testing.T) {
a := SSHAction{
Accept: true,
SessionDuration: 5 * time.Second,
}
got, err := json.Marshal(a)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
const want = `{"accept":true,"sessionDuration":5000000000}`
if string(got) != want {
t.Errorf("Marshal = %s; want %s", got, want)
}
var back SSHAction
if err := json.Unmarshal(got, &back); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if !reflect.DeepEqual(back, a) {
t.Errorf("round trip = %+v; want %+v", back, a)
}
}
+5
View File
@@ -2107,6 +2107,11 @@ func (v SSHActionView) Accept() bool { return v.ж.Accept }
// SessionDuration, if non-zero, is how long the session can stay open // SessionDuration, if non-zero, is how long the session can stay open
// before being forcefully terminated. // before being forcefully terminated.
// It is encoded as an int64 of nanoseconds (Go's time.Duration
// wire format for encoding/json v1). It must not use a jsonv2
// format tag; the mere presence of one makes Go 1.27's
// encoding/json fail to decode the struct. See
// https://github.com/tailscale/tailscale/issues/20528.
func (v SSHActionView) SessionDuration() time.Duration { return v.ж.SessionDuration } func (v SSHActionView) SessionDuration() time.Duration { return v.ж.SessionDuration }
// AllowAgentForwarding, if true, allows accepted connections to forward // AllowAgentForwarding, if true, allows accepted connections to forward
+1 -1
View File
@@ -30,7 +30,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+
github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck github.com/go-json-experiment/json/v1 from tailscale.com/net/routecheck+
L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns
github.com/golang/groupcache/lru from tailscale.com/net/dnscache github.com/golang/groupcache/lru from tailscale.com/net/dnscache
github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/transport/tcp github.com/google/btree from gvisor.dev/gvisor/pkg/tcpip/transport/tcp
+6 -3
View File
@@ -21,6 +21,8 @@ import (
"context" "context"
jsonv2 "github.com/go-json-experiment/json"
jsonv1 "github.com/go-json-experiment/json/v1"
"go4.org/mem" "go4.org/mem"
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/util/ctxkey" "tailscale.com/util/ctxkey"
@@ -379,12 +381,13 @@ func AsJSON(v any) fmt.Formatter {
type asJSONResult struct{ v any } type asJSONResult struct{ v any }
func (a asJSONResult) Format(s fmt.State, verb rune) { func (a asJSONResult) Format(s fmt.State, verb rune) {
v, err := json.Marshal(a.v) // Write directly to s rather than going through json.Marshal's
// returned []byte to avoid an allocation. The explicit v1
// options keep the output identical to encoding/json.
err := jsonv2.MarshalWrite(s, a.v, jsonv1.DefaultOptionsV1())
if err != nil { if err != nil {
fmt.Fprintf(s, "%%!JSON-ERROR:%v", err) fmt.Fprintf(s, "%%!JSON-ERROR:%v", err)
return
} }
s.Write(v)
} }
// TestLogger returns a logger that logs to tb.Logf // TestLogger returns a logger that logs to tb.Logf
+7 -2
View File
@@ -105,7 +105,11 @@ func (o Value[T]) MarshalJSONTo(enc *jsontext.Encoder) error {
if !o.set { if !o.set {
return enc.WriteToken(jsontext.Null) return enc.WriteToken(jsontext.Null)
} }
return jsonv2.MarshalEncode(enc, &o.value) // Pin v2 semantics so the wire format (e.g. nil slices as "[]",
// nil maps as "{}") stays the same even when this method is
// reached via encoding/json v1, which as of Go 1.27 dispatches
// here with v1 options that would otherwise leak into this call.
return jsonv2.MarshalEncode(enc, &o.value, jsonv2.DefaultOptionsV2())
} }
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom]. // UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
@@ -116,7 +120,8 @@ func (o *Value[T]) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
return err return err
} }
o.set = true o.set = true
return jsonv2.UnmarshalDecode(dec, &o.value) // Pin v2 semantics; see MarshalJSONTo.
return jsonv2.UnmarshalDecode(dec, &o.value, jsonv2.DefaultOptionsV2())
} }
// MarshalJSON implements [json.Marshaler]. // MarshalJSON implements [json.Marshaler].
+17 -3
View File
@@ -52,7 +52,15 @@ type serializable[T any] struct {
// when the preference has not been configured. // when the preference has not been configured.
Default T `json:",omitzero"` Default T `json:",omitzero"`
// Metadata is any additional type-agnostic preference metadata to be serialized. // Metadata is any additional type-agnostic preference metadata to be serialized.
Metadata metadata `json:",inline"` //
// The `inline` tag option was renamed to `embed` in Go 1.27's
// encoding/json/v2, but the pinned go-json-experiment module
// (used on older Go versions) only knows `inline`. Each
// implementation ignores the option it doesn't know, so specify
// both until we require Go 1.27 and drop `inline`.
//
//lint:ignore SA5008 staticcheck doesn't know Go 1.27's `embed` option yet
Metadata metadata `json:",inline,embed"`
} }
// preference is an embeddable type that provides a common implementation for // preference is an embeddable type that provides a common implementation for
@@ -165,12 +173,18 @@ var (
// MarshalJSONTo implements [jsonv2.MarshalerTo]. // MarshalJSONTo implements [jsonv2.MarshalerTo].
func (p preference[T]) MarshalJSONTo(out *jsontext.Encoder) error { func (p preference[T]) MarshalJSONTo(out *jsontext.Encoder) error {
return jsonv2.MarshalEncode(out, &p.s) // Pin v2 semantics so the wire format (notably the handling of
// the `inline` and `omitzero` tag options in [serializable])
// stays the same even when this method is reached via
// encoding/json v1, which as of Go 1.27 dispatches here with v1
// options that would otherwise leak into this call.
return jsonv2.MarshalEncode(out, &p.s, jsonv2.DefaultOptionsV2())
} }
// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom]. // UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
func (p *preference[T]) UnmarshalJSONFrom(in *jsontext.Decoder) error { func (p *preference[T]) UnmarshalJSONFrom(in *jsontext.Decoder) error {
return jsonv2.UnmarshalDecode(in, &p.s) // Pin v2 semantics; see MarshalJSONTo.
return jsonv2.UnmarshalDecode(in, &p.s, jsonv2.DefaultOptionsV2())
} }
// MarshalJSON implements [json.Marshaler]. // MarshalJSON implements [json.Marshaler].