The wire does not move with the toolchain

jsonenc chose encoding/json/v2 on the goexperiment.jsonv2 tag, and Go 1.27 sets
that tag by default. Moving a module's go directive to 1.27 therefore changed what
every zip service writes: a zero number or false kept under omitempty, a nil slice
as [] and a nil map as {}, field names matched case-sensitively, map keys unsorted.
hanzoai/cloud went from green to 54 failing wire tests on that bump alone.

jsonenc is stdlib encoding/json and nothing else. Since 1.27 that API runs on the
v2 engine with v1 semantics, so the speed arrives with the toolchain and the bytes
stay put. A test pins the four differences.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-16 19:40:42 -07:00
co-authored by Hanzo Dev
parent 8ddb60a2d8
commit a175872aab
10 changed files with 83 additions and 80 deletions
+1 -2
View File
@@ -168,9 +168,8 @@ for i in $(seq 8); do
go test -run='^$' -bench=Benchmark_PluginHop -benchmem -count=1 -benchtime=3000x .
done
# JSON edge path (v1 vs json/v2)
# JSON edge path (stdlib encoding/json)
go test -run='^$' -bench='BenchmarkJSON' -benchmem -count=6 .
GOEXPERIMENT=jsonv2 go test -run='^$' -bench='BenchmarkJSON' -benchmem -count=6 .
# Medians (requires golang.org/x/perf/cmd/benchstat)
go test -run='^$' -bench='Benchmark_ZipTax|Benchmark_TypedRoute' -benchmem -count=6 . | benchstat -
+7 -6
View File
@@ -4,12 +4,13 @@
// generic In/Out handlers, module.go's extension envelope — goes
// through Marshal / Unmarshal here. There is no other JSON path.
//
// Implementation: two build-tag-gated files swap the body. When the
// binary is compiled with GOEXPERIMENT=jsonv2, the import resolver
// can reach the stdlib's encoding/json/v2 package and the v2.go file
// is selected; otherwise v1.go is selected and we fall back to
// stdlib encoding/json. Either way callers see Marshal / Unmarshal —
// no v1/v2 branching in zip's own code.
// It is stdlib encoding/json and nothing else. Since Go 1.27 that API runs on
// the v2 engine with v1 semantics, so the speed arrives with the toolchain and
// the wire stays where it is. Calling encoding/json/v2 directly would not: v2
// keeps a zero number or false under omitempty, writes a nil slice as [] and a
// nil map as {}, and matches field names case-sensitively. This package used to
// select v2 on the goexperiment.jsonv2 build tag, which Go 1.27 sets by default,
// so moving a module's go directive to 1.27 changed what every response said.
//
// Per HIP-0106 "Wire protocol stack": JSON is the boundary format
// (ingress → gateway → subsystem handler). Inter-subsystem calls use
+14
View File
@@ -0,0 +1,14 @@
package jsonenc
import (
"encoding/json"
)
// Variant names the JSON implementation. zip.New() logs it once at startup.
const Variant = "encoding/json"
// Marshal encodes v as JSON.
func Marshal(v any) ([]byte, error) { return json.Marshal(v) }
// Unmarshal decodes data into v.
func Unmarshal(data []byte, v any) error { return json.Unmarshal(data, v) }
+46
View File
@@ -0,0 +1,46 @@
package jsonenc
import "testing"
// The wire is a contract with every client, so it must not move with the
// toolchain. Each case below is a place where encoding/json/v2 answers
// differently, and Go 1.27 turned v2 on by default.
func TestWireDoesNotFollowTheToolchain(t *testing.T) {
type shape struct {
N int `json:"n,omitempty"`
On bool `json:"on,omitempty"`
List []int `json:"list"`
Map map[string]int `json:"map"`
}
got, err := Marshal(shape{})
if err != nil {
t.Fatal(err)
}
if want := `{"list":null,"map":null}`; string(got) != want {
t.Fatalf("Marshal(zero) = %s, want %s", got, want)
}
// Map keys come out sorted, so a document generated twice is the same bytes.
// Fourteen keys make an unsorted encoder matching by chance negligible.
keys := map[string]int{}
for _, k := range []string{"q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "a", "s", "d", "f"} {
keys[k] = 1
}
got, err = Marshal(keys)
if err != nil {
t.Fatal(err)
}
if want := `{"a":1,"d":1,"e":1,"f":1,"i":1,"o":1,"p":1,"q":1,"r":1,"s":1,"t":1,"u":1,"w":1,"y":1}`; string(got) != want {
t.Fatalf("Marshal(map) = %s, want sorted keys", got)
}
var in struct {
Name string `json:"name"`
}
if err := Unmarshal([]byte(`{"Name":"a"}`), &in); err != nil {
t.Fatal(err)
}
if in.Name != "a" {
t.Fatalf("a field name that differs only in case was dropped: %+v", in)
}
}
-20
View File
@@ -1,20 +0,0 @@
//go:build !goexperiment.jsonv2
package jsonenc
import (
"encoding/json"
)
// Variant reports which JSON implementation backs this build. v2 is
// reachable only under `GOEXPERIMENT=jsonv2`; without that flag the
// stdlib encoding/json/v2 import does not resolve at compile time and
// zip falls back to encoding/json v1. zip.New() logs Variant once at
// startup so operators can spot a missing GOEXPERIMENT in CI.
const Variant = "encoding/json"
// Marshal encodes v as JSON via encoding/json (v1).
func Marshal(v any) ([]byte, error) { return json.Marshal(v) }
// Unmarshal decodes data into v via encoding/json (v1).
func Unmarshal(data []byte, v any) error { return json.Unmarshal(data, v) }
-17
View File
@@ -1,17 +0,0 @@
//go:build goexperiment.jsonv2
package jsonenc
import (
jsonv2 "encoding/json/v2"
)
// Variant reports which JSON implementation backs this build. zip.New()
// logs this once at startup so operators can confirm v2 is active.
const Variant = "encoding/json/v2"
// Marshal encodes v as JSON via encoding/json/v2.
func Marshal(v any) ([]byte, error) { return jsonv2.Marshal(v) }
// Unmarshal decodes data into v via encoding/json/v2.
func Unmarshal(data []byte, v any) error { return jsonv2.Unmarshal(data, v) }
+2 -3
View File
@@ -3,8 +3,7 @@ package js
import "github.com/zap-proto/zip/internal/jsonenc"
// jsonMarshal / jsonUnmarshal route through zip's jsonenc package so the
// embedded-JS bridge uses the same JSON impl as the edge (encoding/json,
// or encoding/json/v2 under GOEXPERIMENT=jsonv2) — one wire format, one
// way to encode it.
// embedded-JS bridge uses the same JSON impl as the edge — one wire format,
// one way to encode it.
func jsonMarshal(v any) ([]byte, error) { return jsonenc.Marshal(v) }
func jsonUnmarshal(b []byte, v any) error { return jsonenc.Unmarshal(b, v) }
+6 -19
View File
@@ -1,15 +1,9 @@
package zip_test
// Head-to-head bench: zip's edge JSON path with encoding/json (v1) vs
// encoding/json/v2. Build select happens via the `goexperiment.jsonv2`
// tag — the same `zip.JSONVariant` constant the benchmarks print is
// what `zip.New` logs at startup.
// Bench for zip's edge JSON path, which is stdlib encoding/json.
//
// Run:
// go test -bench=BenchmarkJSON -benchmem -run=^$ .
// GOEXPERIMENT=jsonv2 go test -bench=BenchmarkJSON -benchmem -run=^$ .
//
// Compare allocations + ns/op between the two invocations.
import (
"bytes"
@@ -55,8 +49,8 @@ var benchReqBody = []byte(`{
}`)
// BenchmarkJSONEdge runs the full edge path: POST a JSON body, zip
// decodes it via c.Bind() (json/v2 if GOEXPERIMENT=jsonv2, else
// json/v1), the handler returns a JSON response, zip encodes it.
// decodes it via c.Bind(), the handler returns a JSON response, zip
// encodes it.
// This is the "JSON at the edge" hot path in production.
func BenchmarkJSONEdge(b *testing.B) {
app := zip.New(zip.Config{
@@ -155,16 +149,9 @@ func BenchmarkJSONUnmarshalOnly(b *testing.B) {
}
}
// TestJSONVariantConstant catches the case where someone breaks the
// build-tag wiring: with GOEXPERIMENT=jsonv2 we expect
// `encoding/json/v2`; without it, `encoding/json`. The constant is
// what zip.New logs at startup, so this is the same value operators
// read in cloud-binary logs.
// TestJSONVariantConstant pins the one JSON implementation zip.New logs.
func TestJSONVariantConstant(t *testing.T) {
switch zip.JSONVariant {
case "encoding/json/v2", "encoding/json":
// ok — exactly one of these must be reported.
default:
t.Fatalf("unexpected JSONVariant %q", zip.JSONVariant)
if zip.JSONVariant != "encoding/json" {
t.Fatalf("JSONVariant = %q, want encoding/json", zip.JSONVariant)
}
}
+2 -4
View File
@@ -60,10 +60,8 @@ type Loader interface {
// moduleEnvelope is the JSON shape every extension runtime receives.
// Same shape across wasm / goja / pyvm / starlark — the host serializes
// once and the guest sees the same bytes regardless of which engine ran
// it. RawMessage is from encoding/json (v1) because the type lives in
// v1 only; both v1 and v2 (encoding/json/v2) honor its MarshalJSON /
// UnmarshalJSON so the envelope serializes identically under either
// build of jsonenc.
// it. RawMessage is encoding/json's, the same package jsonenc encodes
// with.
type moduleEnvelope struct {
Method string `json:"method"`
Path string `json:"path"`
+5 -9
View File
@@ -42,12 +42,9 @@ import (
"github.com/zap-proto/zip/internal/jsonenc"
)
// JSONVariant reports which JSON implementation zip is using in this
// build — "encoding/json/v2" when compiled with GOEXPERIMENT=jsonv2,
// "encoding/json" otherwise. Exposed for cmd/cloud startup logs and
// for tests that need to assert the variant. Per HIP-0106 the wire
// stack is "JSON only at edge, ZAP between services"; this constant
// tells operators which JSON impl is on the edge.
// JSONVariant names the JSON implementation on the edge: stdlib
// encoding/json, whatever the toolchain. Per HIP-0106 the wire stack is
// "JSON only at edge, ZAP between services".
const JSONVariant = jsonenc.Variant
// Handler is zip's request handler signature. Returning an error causes
@@ -410,9 +407,8 @@ func newApp(cfg Config) *App {
// jsonMarshal / jsonUnmarshal route every fiber JSON path through zip's jsonenc
// package: c.JSON(), c.Bind().Body(), and the default error handler when it
// serialises an HTTPError. With GOEXPERIMENT=jsonv2 the underlying impl is
// encoding/json/v2; otherwise encoding/json. Same call site, different
// bytes-out. Named here so [App.fiberConfig] is the one place that says it.
// serialises an HTTPError. Named here so [App.fiberConfig] is the one place
// that says it.
var (
jsonMarshal = jsonenc.Marshal
jsonUnmarshal = jsonenc.Unmarshal