mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
fix(go): a context stops a call into the core
hanzoai/wasm built its runtime without CloseOnContextDone, so neither a context nor the Run bound reached the core. A Step under a cancelled context answered as if nothing had happened, and a module whose dev_abi loops held Open past its deadline, and would hold every garbage collection with it. The pin moves to the hanzoai/wasm that turns it on. A call its context ends closes the instance and answers ErrHandle, which also matches context.Canceled or context.DeadlineExceeded; the last snapshot restores the session. enter read gone after letting go of the session, so a Close on another goroutine raced it. -race finds it when a gone session is stepped and closed at once. The read now happens while the call still holds the session. alloc handed dev_alloc's i32 the whole length, so 4 GiB and 16 bytes were lent as 16 and writing the event poisoned the session. A length past MaxInt32 is ErrNull before the core is asked. Decoding took null for a variant's value, and no union at all where Action, Done or Dispatch hold one. serde refuses both, and so does Go now. Any other missing member reads as its zero value, and the docs say so. Close no longer calls dev_drop and dev_release into an instance it closes on the next line. A test repeats an event the core refuses and an answer the host refuses: releasing the loan only on success grows memory from 1.4 MB to 30 MB, freeing the answer only on success from 1.18 MB to 1.31 MB, and each fails. Restore's doc says the checksum is not a signature. The suite passes under -race here, and in linux/arm64 and linux/amd64 containers. Step, Snapshot and Restore now take about 1.3, 1.9 and 2.2 times as long, measured on a loaded machine: the cost of the checks. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+16
-3
@@ -25,6 +25,12 @@ import dev "github.com/hanzoai/dev/go"
|
||||
A `Core` is safe for concurrent use. A `Session` is not: it runs one call at a
|
||||
time, and a call made while another is running answers `ErrBusy`.
|
||||
|
||||
A call runs under its `ctx`, and each call into the instance under the 5s
|
||||
hanzoai/wasm allows one. A call either one ends stops where it stands and ends
|
||||
its session: it answers `ErrHandle`, which matches `context.Canceled` or
|
||||
`context.DeadlineExceeded` too, and every call after it answers `ErrHandle`.
|
||||
Restore the last snapshot to carry on.
|
||||
|
||||
The statuses of `crates/ffi/include/dev.h` are errors to match with
|
||||
`errors.Is`: `ErrHandle`, `ErrMalformed`, `ErrPoison`, `ErrPanic`, `ErrNull`,
|
||||
`ErrBusy` and `ErrFull`. A trap in the core, such as a panic or a refused
|
||||
@@ -42,6 +48,11 @@ refused as `ErrMalformed` before the core sees it.
|
||||
dev.Event{Exec: &dev.Output{ID: a.ID, Text: out, Failed: code != 0}}
|
||||
dev.Event{Timer: true}
|
||||
|
||||
Reading one refuses what serde refuses of a union: a name it does not have, two
|
||||
names, `null` for a variant's value, or no union at all where a struct holds
|
||||
one. Any other member that is missing reads as its zero value, which serde
|
||||
refuses unless the member is an `Option`.
|
||||
|
||||
Text crosses as UTF-8. A host that has read bytes that are not UTF-8 must turn
|
||||
them into text before it steps with them.
|
||||
|
||||
@@ -105,9 +116,11 @@ instead of an effect run twice. The core refuses a turn id it has already
|
||||
accepted and a result for an id it is not waiting on, so replaying an event
|
||||
is safe.
|
||||
|
||||
A restored snapshot continues the id sequence where it stopped. Intact bytes do
|
||||
not prove the snapshot agrees with the journal. One older than the journal
|
||||
mints ids the host has already dispatched, so reconcile the two before
|
||||
A restored snapshot continues the id sequence where it stopped. Its checksum
|
||||
catches damage in storage, and it is not a signature: a host that keeps
|
||||
snapshots where others can write must authenticate them itself. Intact bytes do
|
||||
not prove the snapshot agrees with the journal either. One older than the
|
||||
journal mints ids the host has already dispatched, so reconcile the two before
|
||||
dispatching again.
|
||||
|
||||
## The module
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
json "encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/hanzoai/wasm"
|
||||
@@ -121,10 +122,12 @@ func (c *Core) New(ctx context.Context, cfg Config) (*Session, error) {
|
||||
|
||||
// Restore rebuilds a session from its Snapshot, in an instance of its own.
|
||||
//
|
||||
// A snapshot that was truncated, edited, or written under another ABI answers
|
||||
// ErrMalformed. Intact bytes are not proof that the sequence agrees with the
|
||||
// host's journal: a snapshot older than the journal mints again ids the host
|
||||
// has already dispatched under, so reconcile the two before dispatching.
|
||||
// A snapshot damaged in storage — truncated, corrupted, or written under
|
||||
// another ABI — answers ErrMalformed. The checksum is not a signature: a host
|
||||
// that stores snapshots where others can write must authenticate them itself.
|
||||
// Intact bytes are not proof that the sequence agrees with the host's journal
|
||||
// either: a snapshot older than the journal mints again ids the host has
|
||||
// already dispatched under, so reconcile the two before dispatching.
|
||||
func (c *Core) Restore(ctx context.Context, state []byte) (*Session, error) {
|
||||
return c.start(ctx, "dev_restore", state)
|
||||
}
|
||||
@@ -149,11 +152,17 @@ func (c *Core) start(ctx context.Context, name string, in []byte) (*Session, err
|
||||
// A Session is not safe for concurrent use. It runs one call at a time and
|
||||
// does not queue: a call that arrives while another is running answers
|
||||
// ErrBusy, as DEV_BUSY does in C, and never reaches the instance.
|
||||
//
|
||||
// A call runs under its ctx, and each call into the instance under the 5s
|
||||
// hanzoai/wasm allows one. A call either one ends is stopped where it stands,
|
||||
// and the session with it: it answers ErrHandle, which matches
|
||||
// context.Canceled or context.DeadlineExceeded too, and every call after
|
||||
// answers ErrHandle. Restore the last Snapshot to carry on.
|
||||
type Session struct {
|
||||
inst *wasm.Instance
|
||||
handle uint64
|
||||
// out is the session's one out-parameter, lent at birth and released at
|
||||
// Close: the handle is written there, then every dev_buf after it.
|
||||
// out is the session's one out-parameter, lent at birth and gone with the
|
||||
// instance: the handle is written there, then every dev_buf after it.
|
||||
out uint32
|
||||
busy atomic.Bool
|
||||
// gone is what every call answers once the instance can answer no more:
|
||||
@@ -212,9 +221,9 @@ func (s *Session) Snapshot(ctx context.Context) ([]byte, error) {
|
||||
return state, err
|
||||
}
|
||||
|
||||
// Close drops the session and closes its instance, which takes every byte the
|
||||
// session held with it. A call after Close answers ErrHandle, and a second
|
||||
// Close answers nil.
|
||||
// Close closes the session's instance, and the session and every byte it held
|
||||
// go with it. A call after Close answers ErrHandle, and a second Close answers
|
||||
// nil.
|
||||
func (s *Session) Close(ctx context.Context) error {
|
||||
if !s.busy.CompareAndSwap(false, true) {
|
||||
return ErrBusy
|
||||
@@ -223,14 +232,6 @@ func (s *Session) Close(ctx context.Context) error {
|
||||
if s.inst == nil {
|
||||
return nil
|
||||
}
|
||||
// A handle is never zero, and an instance that has trapped or closed is
|
||||
// not called again: either way, what these would free goes with it.
|
||||
if s.handle != 0 {
|
||||
_, _ = s.call(ctx, "dev_drop", s.handle)
|
||||
}
|
||||
if s.out != 0 {
|
||||
_, _ = s.call(ctx, "dev_release", uint64(s.out), buf)
|
||||
}
|
||||
err := s.inst.Close(ctx)
|
||||
s.inst, s.gone = nil, ErrHandle
|
||||
return err
|
||||
@@ -253,13 +254,15 @@ func (s *Session) open(ctx context.Context, name string, in []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// enter holds the session for one call. What a gone session answers is read
|
||||
// before it lets go: after, a Close on another goroutine may be writing it.
|
||||
func (s *Session) enter() error {
|
||||
if !s.busy.CompareAndSwap(false, true) {
|
||||
return ErrBusy
|
||||
}
|
||||
if s.gone != nil {
|
||||
if gone := s.gone; gone != nil {
|
||||
s.leave()
|
||||
return s.gone
|
||||
return gone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -284,14 +287,15 @@ func (s *Session) call(ctx context.Context, name string, args ...uint64) (uint64
|
||||
}
|
||||
|
||||
// lose answers for a call the instance could not finish, and marks what every
|
||||
// later call answers. An instance that has closed holds no session: ErrHandle.
|
||||
// Anything else is a trap — a panic, or memory the core needed and was refused
|
||||
// — after which the instance's memory is not trusted again: ErrPanic now,
|
||||
// ErrPoison after.
|
||||
// later call answers. An instance that has closed holds no session: ErrHandle,
|
||||
// wrapping why it closed, so a call its context ended matches that context's
|
||||
// error too. Anything else is a trap — a panic, or memory the core needed and
|
||||
// was refused — after which the instance's memory is not trusted again:
|
||||
// ErrPanic now, ErrPoison after.
|
||||
func (s *Session) lose(err error) error {
|
||||
if _, ok := errors.AsType[*sys.ExitError](err); ok {
|
||||
s.gone = ErrHandle
|
||||
return fmt.Errorf("%w: %v", ErrHandle, err)
|
||||
return fmt.Errorf("%w: %w", ErrHandle, err)
|
||||
}
|
||||
s.gone = ErrPoison
|
||||
return fmt.Errorf("%w: %v", ErrPanic, err)
|
||||
@@ -309,8 +313,13 @@ func status(code uint64, err error) error {
|
||||
return statuses[n]
|
||||
}
|
||||
|
||||
// alloc borrows n bytes inside the instance.
|
||||
// alloc borrows n bytes inside the instance. dev_alloc's length is an i32,
|
||||
// and the core lends no more than math.MaxInt32 bytes, so a longer n is
|
||||
// refused here rather than cut to its low 32 bits.
|
||||
func (s *Session) alloc(ctx context.Context, n int) (uint32, error) {
|
||||
if n > math.MaxInt32 {
|
||||
return 0, fmt.Errorf("%w: %d bytes cannot be lent", ErrNull, n)
|
||||
}
|
||||
ptr, err := s.call(ctx, "dev_alloc", uint64(n))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
+199
@@ -6,9 +6,11 @@ import (
|
||||
json "encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// shared is one Core for every test that does not close its own. Open is the
|
||||
@@ -355,6 +357,88 @@ func TestAClosedCoreEndsItsSessions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A call its context ends is stopped where it stands, and the session with
|
||||
// it: ErrHandle, matching the context's own error too, and ErrHandle for every
|
||||
// call after. The snapshot taken before carries the session on.
|
||||
func TestAStepItsContextEndsEndsTheSession(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
c := core(t)
|
||||
cancelled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
past, stop := context.WithDeadline(ctx, time.Now().Add(-time.Hour))
|
||||
defer stop()
|
||||
for _, ended := range []struct {
|
||||
ctx context.Context
|
||||
err error
|
||||
}{{cancelled, context.Canceled}, {past, context.DeadlineExceeded}} {
|
||||
s := session(t, c, Config{})
|
||||
step(t, s, turn(1, "fix the build"))
|
||||
state, err := s.Snapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer := Event{Model: &Answer{ID: 1, Reply: Reply{Text: new("done")}}}
|
||||
if actions, err := s.Step(ended.ctx, answer); !errors.Is(err, ErrHandle) || !errors.Is(err, ended.err) {
|
||||
t.Errorf("a step under %v answered %s, %v", ended.err, show(actions), err)
|
||||
}
|
||||
if _, err := s.Step(ctx, answer); !errors.Is(err, ErrHandle) {
|
||||
t.Errorf("the step after %v answered %v", ended.err, err)
|
||||
}
|
||||
if _, err := s.Snapshot(ctx); !errors.Is(err, ErrHandle) {
|
||||
t.Errorf("a snapshot after %v answered %v", ended.err, err)
|
||||
}
|
||||
if fresh, err := c.New(ended.ctx, Config{}); !errors.Is(err, ErrHandle) || !errors.Is(err, ended.err) {
|
||||
t.Errorf("New under %v answered %v", ended.err, err)
|
||||
if fresh != nil {
|
||||
_ = fresh.Close(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
restored, err := c.Restore(ctx, state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same(t, "the restored session", step(t, restored, answer), []Action{
|
||||
{ID: 2, Op: Op{Emit: new("done")}},
|
||||
{ID: 3, Op: Op{Save: true}},
|
||||
{ID: 4, Op: Op{Done: &Done{Turn: 1, Outcome: Outcome{Complete: true}}}},
|
||||
})
|
||||
_ = restored.Close(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Open is bounded the same way: a module whose dev_abi never returns is
|
||||
// stopped when the context ends, rather than holding its goroutine, and every
|
||||
// garbage collection after, forever.
|
||||
func TestOpenStopsAModuleThatNeverAnswers(t *testing.T) {
|
||||
// (module (func (export "dev_abi") (result i32) (loop (br 0)) (i32.const 1)))
|
||||
spin := []byte{
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f,
|
||||
0x03, 0x02, 0x01, 0x00,
|
||||
0x07, 0x0b, 0x01, 0x07, 'd', 'e', 'v', '_', 'a', 'b', 'i', 0x00, 0x00,
|
||||
0x0a, 0x0b, 0x01, 0x09, 0x00, 0x03, 0x40, 0x0c, 0x00, 0x0b, 0x41, 0x01, 0x0b,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
c, err := open(ctx, spin)
|
||||
if err == nil {
|
||||
_ = c.Close(context.Background())
|
||||
}
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("open answered %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("open is still running 10s after its deadline")
|
||||
}
|
||||
}
|
||||
|
||||
// A Session runs one call at a time and refuses, rather than queues, the
|
||||
// next: the instance never sees two.
|
||||
func TestASessionAnswersOneCallAtATime(t *testing.T) {
|
||||
@@ -376,6 +460,60 @@ func TestASessionAnswersOneCallAtATime(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// One goroutine steps a session whose instance has gone while another closes
|
||||
// it, and the session's busy flag is all that orders them. Under -race this
|
||||
// holds a call to reading what it answers while it still holds the flag: read
|
||||
// after it lets go, the answer races the Close that writes it.
|
||||
func TestAGoneSessionSteppedAndClosedAtOnce(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
c, err := Open(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sessions []*Session
|
||||
for range 16 {
|
||||
s, err := c.New(ctx, Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
if err := c.Close(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range sessions {
|
||||
// The first call finds the instance closed with the Core, and marks the
|
||||
// session gone.
|
||||
if _, err := s.Step(ctx, turn(1, "fix the build")); !errors.Is(err, ErrHandle) {
|
||||
t.Fatalf("a step after the Core closed answered %v", err)
|
||||
}
|
||||
stepped := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
for i := range 200 {
|
||||
_, err := s.Step(ctx, turn(1, "fix the build"))
|
||||
if !errors.Is(err, ErrHandle) && !errors.Is(err, ErrBusy) {
|
||||
t.Errorf("a step on a gone session answered %v", err)
|
||||
}
|
||||
if i == 0 {
|
||||
close(stepped)
|
||||
}
|
||||
}
|
||||
})
|
||||
<-stepped
|
||||
for {
|
||||
err := s.Close(ctx)
|
||||
if !errors.Is(err, ErrBusy) {
|
||||
if err != nil {
|
||||
t.Errorf("Close answered %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// Every session has an instance of its own, so thirty-two of them stepped at
|
||||
// once each see their own conversation and their own ids, and nothing else.
|
||||
func TestSessionsSteppedAtOnceStayApart(t *testing.T) {
|
||||
@@ -475,6 +613,49 @@ func TestTenThousandStepsDoNotGrowMemory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A refused call gives back what it borrowed too. The core refusing an event
|
||||
// it cannot read, and the host refusing an answer it was handed, each release
|
||||
// the event's loan and free the answer, so a thousand of each leave the
|
||||
// instance's memory as large as it was.
|
||||
func TestRefusedCallsDoNotGrowMemory(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
s := session(t, core(t), Config{})
|
||||
prompt := strings.Repeat("fix the build ", 1024)
|
||||
step(t, s, turn(1, prompt))
|
||||
|
||||
unreadable := []byte(`{"Turn":{"id":2,"prompt":"` + prompt + `"}`)
|
||||
again, err := json.Marshal(turn(1, prompt))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused := errors.New("the host refuses the answer")
|
||||
refuse := func() {
|
||||
t.Helper()
|
||||
err := s.step(ctx, unreadable, func(out []byte) error {
|
||||
t.Errorf("an event the core cannot read was answered with %s", out)
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, ErrMalformed) {
|
||||
t.Fatalf("an event the core cannot read answered %v", err)
|
||||
}
|
||||
// The core answers a redelivered turn with no actions, and the host
|
||||
// refuses even that.
|
||||
if err := s.step(ctx, again, func([]byte) error { return refused }); !errors.Is(err, refused) {
|
||||
t.Fatalf("an answer the host refused answered %v", err)
|
||||
}
|
||||
}
|
||||
for range 10 {
|
||||
refuse()
|
||||
}
|
||||
before := s.inst.Memory().Size()
|
||||
for range 1000 {
|
||||
refuse()
|
||||
}
|
||||
if after := s.inst.Memory().Size(); after != before {
|
||||
t.Errorf("2,000 refusals grew memory from %d to %d bytes", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// A core that is refused memory it needs traps. The trap is its session's
|
||||
// alone: that session answers ErrPanic, then ErrPoison until it is closed,
|
||||
// and a session beside it carries on.
|
||||
@@ -508,6 +689,24 @@ func TestATrapPoisonsOnlyItsSession(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// A length the core's i32 cannot carry is refused before the core is asked,
|
||||
// rather than cut to its low 32 bits: 4 GiB and 16 bytes would be lent as 16,
|
||||
// and writing the event into that would poison the session.
|
||||
func TestALengthTheCoreCannotTakeIsNull(t *testing.T) {
|
||||
if math.MaxInt == math.MaxInt32 {
|
||||
t.Skip("no slice here is longer than the core can lend")
|
||||
}
|
||||
s := session(t, core(t), Config{})
|
||||
for _, n := range []uint64{math.MaxInt32 + 1, 1<<32 + 16} {
|
||||
if ptr, err := s.alloc(t.Context(), int(n)); !errors.Is(err, ErrNull) {
|
||||
t.Errorf("%d bytes were lent at %#x, %v", n, ptr, err)
|
||||
}
|
||||
}
|
||||
same(t, "the step after", step(t, s, turn(1, "fix the build")), []Action{
|
||||
{ID: 1, Op: Op{Model: &Ask{Messages: []Message{{User: new("fix the build")}}}}},
|
||||
})
|
||||
}
|
||||
|
||||
// An event too large to lend is refused before the core sees it, and the
|
||||
// session is left as it was.
|
||||
func TestAnEventTooLargeToLendIsNull(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/hanzoai/dev/go
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/hanzoai/wasm v0.0.0-20260919010011-c98759db1386
|
||||
github.com/hanzoai/wasm v0.0.0-20260919121249-5b3376fa4533
|
||||
github.com/tetratelabs/wazero v1.11.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
github.com/hanzoai/wasm v0.0.0-20260919010011-c98759db1386 h1:t4Vyu3/XCfsP5ZTvUzHKYyWYRtWiDH0ZW7jpcCtGEBw=
|
||||
github.com/hanzoai/wasm v0.0.0-20260919010011-c98759db1386/go.mod h1:HeIktRQpkAudLsAobKdB9V/VNtzmF0o/nP8eQAdMnZQ=
|
||||
github.com/hanzoai/wasm v0.0.0-20260919121249-5b3376fa4533 h1:xsBjUid6i80bY8JWi6GGN5A6lFa62Lzo6HnJ9HgTmRM=
|
||||
github.com/hanzoai/wasm v0.0.0-20260919121249-5b3376fa4533/go.mod h1:HeIktRQpkAudLsAobKdB9V/VNtzmF0o/nP8eQAdMnZQ=
|
||||
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
|
||||
+60
-6
@@ -17,6 +17,11 @@ import (
|
||||
// Encoded, a union is what serde writes for an enum: {"Turn":{...}} for a
|
||||
// variant with a value, "Timer" for one without. An Option is a pointer, and a
|
||||
// nil one is null.
|
||||
//
|
||||
// Read, a union is refused where serde would refuse it: a name it does not
|
||||
// have, two names, null for a variant's value, or no union at all where a
|
||||
// struct holds one. Any other member that is missing reads as its zero value,
|
||||
// which serde refuses unless the member is an Option.
|
||||
|
||||
// Config is what a session is born with.
|
||||
type Config struct {
|
||||
@@ -223,8 +228,43 @@ func (c *Call) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return decode(de
|
||||
func (m Message) MarshalJSONTo(enc *jsontext.Encoder) error { return encode(enc, m) }
|
||||
func (m *Message) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return decode(dec, m) }
|
||||
|
||||
// encode writes the one variant a union sets.
|
||||
func encode(enc *jsontext.Encoder, union any) error {
|
||||
// Action, Done and Dispatch each hold a union. A member that is missing reads
|
||||
// as its zero value, and a union's is no variant at all, so each checks its
|
||||
// union once it has read the rest.
|
||||
|
||||
func (a *Action) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
|
||||
type fields Action
|
||||
*a = Action{}
|
||||
if err := json.UnmarshalDecode(dec, (*fields)(a)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := variant(a.Op)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Done) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
|
||||
type fields Done
|
||||
*d = Done{}
|
||||
if err := json.UnmarshalDecode(dec, (*fields)(d)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := variant(d.Outcome)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Dispatch) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
|
||||
type fields Dispatch
|
||||
*d = Dispatch{}
|
||||
if err := json.UnmarshalDecode(dec, (*fields)(d)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := variant(d.Call)
|
||||
return err
|
||||
}
|
||||
|
||||
// variant is the index of the one variant a union sets, and an error when it
|
||||
// sets none or two.
|
||||
func variant(union any) (int, error) {
|
||||
v := reflect.ValueOf(union)
|
||||
t := v.Type()
|
||||
set := -1
|
||||
@@ -233,14 +273,24 @@ func encode(enc *jsontext.Encoder, union any) error {
|
||||
continue
|
||||
}
|
||||
if set >= 0 {
|
||||
return fmt.Errorf("%s sets both %s and %s", t.Name(), t.Field(set).Name, t.Field(i).Name)
|
||||
return 0, fmt.Errorf("%s sets both %s and %s", t.Name(), t.Field(set).Name, t.Field(i).Name)
|
||||
}
|
||||
set = i
|
||||
}
|
||||
if set < 0 {
|
||||
return fmt.Errorf("%s sets no variant", t.Name())
|
||||
return 0, fmt.Errorf("%s sets no variant", t.Name())
|
||||
}
|
||||
name, value := t.Field(set).Name, v.Field(set)
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// encode writes the one variant a union sets.
|
||||
func encode(enc *jsontext.Encoder, union any) error {
|
||||
set, err := variant(union)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v := reflect.ValueOf(union)
|
||||
name, value := v.Type().Field(set).Name, v.Field(set)
|
||||
if value.Kind() == reflect.Bool {
|
||||
return enc.WriteToken(jsontext.String(name))
|
||||
}
|
||||
@@ -257,7 +307,8 @@ func encode(enc *jsontext.Encoder, union any) error {
|
||||
}
|
||||
|
||||
// decode reads one variant into a union, and only one: a name the union does
|
||||
// not have, or an object naming two, is refused rather than guessed at.
|
||||
// not have, an object naming two, or a variant whose value is null, is refused
|
||||
// rather than guessed at.
|
||||
func decode(dec *jsontext.Decoder, union any) error {
|
||||
v := reflect.ValueOf(union).Elem()
|
||||
t := v.Type()
|
||||
@@ -282,6 +333,9 @@ func decode(dec *jsontext.Decoder, union any) error {
|
||||
if !ok || f.Type.Kind() != reflect.Pointer {
|
||||
return fmt.Errorf("%s has no variant %q with a value", t.Name(), tok.String())
|
||||
}
|
||||
if dec.PeekKind() == 'n' {
|
||||
return fmt.Errorf("%s variant %s carries null, not a value", t.Name(), f.Name)
|
||||
}
|
||||
value := reflect.New(f.Type.Elem())
|
||||
if err := json.UnmarshalDecode(dec, value.Interface()); err != nil {
|
||||
return err
|
||||
|
||||
+13
-1
@@ -185,23 +185,35 @@ func TestTheCoreRefusesWhatItCannotRead(t *testing.T) {
|
||||
}
|
||||
|
||||
// Go reads a union as strictly as it writes one: a single variant, named as
|
||||
// the protocol names it, carrying a value exactly when the variant does.
|
||||
// the protocol names it, carrying a value exactly when the variant does, and
|
||||
// never null for one. A union that is missing is no variant at all, and is
|
||||
// refused where it is missing.
|
||||
func TestAUnionIsReadAsOneVariant(t *testing.T) {
|
||||
for _, payload := range []string{
|
||||
`null`, `[]`, `{}`, `7`,
|
||||
`"Bogus"`, `{"Bogus":{}}`,
|
||||
`"save"`, `{"emit":"x"}`,
|
||||
`"Emit"`, `{"Save":null}`, `{"Save":true}`,
|
||||
`{"Emit":null}`, `{"Model":null}`, `{"Done":null}`,
|
||||
`{"Emit":"x","Save":null}`,
|
||||
`{"Emit":"x","Done":{"turn":1,"outcome":"Complete"}}`,
|
||||
`{"Done":{"turn":1,"outcome":"Finished"}}`,
|
||||
`{"Done":{"turn":1,"outcome":"Complete","by":"me"}}`,
|
||||
`{"Done":{"turn":1}}`,
|
||||
`{"Done":{"turn":1,"outcome":null}}`,
|
||||
`{"Model":{"model":null,"prelude":null,"messages":[{"Agent":{"text":null,"calls":[{"id":2}]}}]}}`,
|
||||
} {
|
||||
var op Op
|
||||
if err := json.Unmarshal([]byte(payload), &op, json.RejectUnknownMembers(true)); err == nil {
|
||||
t.Errorf("%s was read as %s", payload, show(op))
|
||||
}
|
||||
}
|
||||
for _, payload := range []string{`[{"id":1}]`, `[{"id":1,"op":null}]`} {
|
||||
var actions []Action
|
||||
if err := json.Unmarshal([]byte(payload), &actions, json.RejectUnknownMembers(true)); err == nil {
|
||||
t.Errorf("%s was read as %#v", payload, actions)
|
||||
}
|
||||
}
|
||||
for payload, want := range map[string]Op{
|
||||
`"Save"`: {Save: true},
|
||||
`{"Emit":""}`: {Emit: new("")},
|
||||
|
||||
Reference in New Issue
Block a user