Files

1463 lines
45 KiB
Go

package plugin
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"testing"
"time"
)
// fakeHost is a host whose stdout the test writes, so its real read and
// dispatch run without Bun. What magpie writes to the child (fetch, credit,
// abort) is drained into f.sent, so a test can see the control traffic without
// a writer goroutine parked on a pipe.
type fakeHost struct {
*host
pw *io.PipeWriter
outMu sync.Mutex
sent []map[string]any
}
func newFakeHost(t *testing.T) *fakeHost {
return newFakeHostLine(t, 0)
}
// newFakeHostLine is newFakeHost with the child's line cap lowered, for the
// too-long-line case. The cap is per host, so no test mutates a shared value
// while another test's reader is running.
func newFakeHostLine(t *testing.T, maxLine int) *fakeHost {
t.Helper()
pr, pw := io.Pipe()
hctx, hcancel := context.WithCancel(context.Background())
h := &host{
cmd: &exec.Cmd{}, in: discardCloser{},
calls: map[int64]*call{},
dead: make(chan struct{}),
slots: make(chan struct{}, callLimit()),
streams: make(chan struct{}, streamLimit()),
out: make(chan writeReq, callLimit()),
ctrl: map[int64]*ctrl{},
ctrlWake: make(chan struct{}, 1),
ctx: hctx,
cancel: hcancel,
maxLine: maxLine,
}
f := &fakeHost{host: h, pw: pw}
go h.read(pr)
go h.ctrlLoop()
go f.drain()
t.Cleanup(func() {
_ = pw.CloseWithError(io.EOF)
hcancel()
})
return f
}
// drain records what magpie wrote to the child, until the host is gone.
func (f *fakeHost) drain() {
for {
select {
case <-f.host.dead:
return
case req := <-f.out:
f.host.tookWrite()
var v map[string]any
if json.Unmarshal(req.b, &v) == nil {
f.outMu.Lock()
f.sent = append(f.sent, v)
f.outMu.Unlock()
}
if req.done != nil {
req.done <- nil
}
}
}
}
// wrote is every message magpie wrote to the child so far.
func (f *fakeHost) wrote() []map[string]any {
f.outMu.Lock()
defer f.outMu.Unlock()
return append([]map[string]any(nil), f.sent...)
}
// methods are the methods magpie wrote, in order.
func (f *fakeHost) methods() []string {
var out []string
for _, m := range f.wrote() {
s, _ := m["method"].(string)
out = append(out, s)
}
return out
}
// credited is how many bytes magpie gave back as credit so far.
func (f *fakeHost) credited() int {
var n int
for _, m := range f.wrote() {
if m["method"] != "credit" {
continue
}
if p, ok := m["params"].(map[string]any); ok {
if v, ok := p["n"].(float64); ok {
n += int(v)
}
}
}
return n
}
// line is one message the host would have written on its stdout.
func (f *fakeHost) line(v any) {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
_, _ = f.pw.Write(append(b, '\n'))
}
func (f *fakeHost) head(id int64) {
f.line(map[string]any{"id": id, "event": "head", "status": 200, "headers": map[string]string{}})
}
func (f *fakeHost) chunk(id int64, data string) {
f.line(map[string]any{"id": id, "event": "chunk", "data": base64.StdEncoding.EncodeToString([]byte(data))})
}
func (f *fakeHost) answer(id int64, why string) {
if why == "" {
f.line(map[string]any{"id": id, "result": nil})
return
}
f.line(map[string]any{"id": id, "error": map[string]string{"message": why}})
}
// next is the next message queued for the call, as Fetch takes its head.
func (f *fakeHost) next(t *testing.T, c *call) message {
t.Helper()
for {
if m, ok := c.pop(); ok {
return m
}
select {
case <-c.wake:
case <-time.After(5 * time.Second):
t.Fatal("no message arrived")
}
}
}
// reply writes a stream's head and waits for the test to take it, as Fetch
// takes its head, then writes the rest of the reply in the background.
func reply(t *testing.T, f *fakeHost, id int64, c *call, rest func()) message {
t.Helper()
popped := make(chan struct{})
go func() {
f.head(id)
<-popped
rest()
}()
h := f.next(t, c)
close(popped)
return h
}
// readBody is the reply body Fetch hands out for the call.
func readBody(f *fakeHost, id int64, c *call, ctx context.Context) *body {
return &body{h: f.host, id: id, c: c, ctx: ctx, closed: make(chan struct{})}
}
// begin accepts the synthetic fetch before the test dispatches its reply.
func (f *fakeHost) begin(t *testing.T, stream bool) (int64, *call) {
t.Helper()
id, c, err := f.host.begin(stream)
if err != nil {
t.Fatalf("begin: %v", err)
}
c.transition(callAccepted, nil, nil)
return id, c
}
func waitFor(t *testing.T, what string, f func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if f() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("%s didn't happen", what)
}
// registered is whether the call is still the host's to answer.
func (f *fakeHost) registered(id int64) bool {
f.mu.Lock()
defer f.mu.Unlock()
_, ok := f.calls[id]
return ok
}
// queued is how many chunks the call holds for its reader.
func (c *call) queuedChunks() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.queue)
}
// owed is how many decoded bytes the child sent and the reader has not yet
// given back.
func (c *call) owedBytes() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.owed
}
// slotsFree is how many admission slots are free.
func (f *fakeHost) slotsFree() int { return cap(f.host.slots) - len(f.host.slots) }
// chunkAt is the i-th chunk of a stream, "" apart from its place.
func chunkAt(i int) string { return strconvItoa(i) + "\n" }
// chunks is the first n chunks of a stream, as one body.
func chunks(n int) string {
var b strings.Builder
for i := range n {
b.WriteString(chunkAt(i))
}
return b.String()
}
func strconvItoa(i int) string {
if i == 0 {
return "0"
}
var d []byte
for i > 0 {
d = append([]byte{byte('0' + i%10)}, d...)
i /= 10
}
return string(d)
}
type discardCloser struct{}
func (discardCloser) Write(p []byte) (int, error) { return len(p), nil }
func (discardCloser) Close() error { return nil }
// failingIn is a child's stdin whose writes fail, as a pipe to a dead child.
type failingIn struct{}
func (failingIn) Write(p []byte) (int, error) { return 0, errors.New("broken pipe") }
func (failingIn) Close() error { return nil }
// TestHostWriterFailureWakesWaiters: a stdin write that fails ends the host, so
// a call settling with an abort owed gets its slot back instead of waiting for
// a write result that will never come, and later enqueues return rather than
// park. The host's stdout staying open must not be needed to notice.
func TestHostWriterFailureWakesWaiters(t *testing.T) {
hctx, hcancel := context.WithCancel(context.Background())
h := &host{
in: failingIn{}, calls: map[int64]*call{},
dead: make(chan struct{}),
slots: make(chan struct{}, callLimit()),
streams: make(chan struct{}, streamLimit()),
out: make(chan writeReq, callLimit()),
ctrl: map[int64]*ctrl{},
ctrlWake: make(chan struct{}, 1),
ctx: hctx,
cancel: hcancel,
}
go h.writerLoop()
go h.ctrlLoop()
_, c, err := h.begin(true)
if err != nil {
t.Fatal(err)
}
c.transition(callAccepted, nil, nil)
c.settle(context.Canceled) // an abort is owed: its slot waits on the write
waitFor(t, "the failed host freed the abort's slot", func() bool {
return len(h.slots) == 0 && len(h.streams) == 0
})
select {
case <-h.dead:
default:
t.Fatal("a failed write did not end the host")
}
done := make(chan error, 1)
go func() { done <- h.enqueue(context.Background(), map[string]any{"id": 1}) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("enqueue parked after the host's write failed")
}
}
// TestHostWriteFailureWakesRPCAndHeadWaiters: with the child's stdin broken but
// its stdout still open, an RPC waiting on a context that never ends and a
// Fetch waiting for its head both return rather than wait for a host that is
// gone.
func TestHostWriteFailureWakesRPCAndHeadWaiters(t *testing.T) {
pr, pw := io.Pipe()
hctx, hcancel := context.WithCancel(context.Background())
h := &host{
cmd: &exec.Cmd{}, in: failingIn{},
calls: map[int64]*call{},
dead: make(chan struct{}),
slots: make(chan struct{}, callLimit()),
streams: make(chan struct{}, streamLimit()),
out: make(chan writeReq, callLimit()),
ctrl: map[int64]*ctrl{},
ctrlWake: make(chan struct{}, 1),
ctx: hctx,
cancel: hcancel,
}
go h.read(pr) // the child's stdout stays open: only stdin is broken
go h.writerLoop()
go h.ctrlLoop()
t.Cleanup(func() { _ = pw.CloseWithError(io.EOF) })
_, c, err := h.begin(true)
if err != nil {
t.Fatal(err)
}
c.transition(callAccepted, nil, nil)
// an RPC whose context never ends, waiting for an answer that will not come
rpc := make(chan error, 1)
go func() { rpc <- h.call(context.Background(), "prompt", map[string]any{}, nil) }()
select {
case err := <-rpc:
if err == nil {
t.Fatal("an RPC succeeded on a host whose stdin is broken")
}
case <-time.After(5 * time.Second):
t.Fatal("an RPC waiter was left waiting after the write failed")
}
// and a Fetch waiting for its head
head := make(chan error, 1)
go func() {
_, _, err := c.headRead(context.Background())
head <- err
}()
select {
case err := <-head:
if err == nil {
t.Fatal("a head read succeeded on a host whose stdin is broken")
}
case <-time.After(5 * time.Second):
t.Fatal("a head waiter was left waiting after the write failed")
}
}
// TestHostStreamRefusesRepeatedHeadAndUnknownEvents: a second head, or an event
// the host does not know, is a broken child — refused rather than queued for
// free, so a run of zero-cost lines cannot grow the queue without bound.
func TestHostStreamRefusesRepeatedHeadAndUnknownEvents(t *testing.T) {
t.Run("second head", func(t *testing.T) {
f := newFakeHost(t)
_, c := f.begin(t, true)
if !c.push(message{Event: "head"}) {
t.Fatal("the first head was refused")
}
if c.push(message{Event: "head"}) {
t.Fatal("a second head was queued")
}
if got := c.givenUp(); !errors.Is(got, errProtocolOverrun) {
t.Fatalf("givenUp = %v, want errProtocolOverrun", got)
}
if n := c.queuedChunks(); n != 0 {
t.Fatalf("%d chunks left after a repeated head", n)
}
})
t.Run("unknown event", func(t *testing.T) {
f := newFakeHost(t)
_, c := f.begin(t, true)
if c.push(message{Event: "surprise", Data: "YQ=="}) {
t.Fatal("an unknown event was queued")
}
if got := c.givenUp(); !errors.Is(got, errProtocolOverrun) {
t.Fatalf("givenUp = %v, want errProtocolOverrun", got)
}
})
}
// TestHostReadRejectsAnUnboundedLine: a child that never ends a line fails the
// host rather than making it allocate without bound.
func TestHostReadRejectsAnUnboundedLine(t *testing.T) {
f := newFakeHostLine(t, 1024)
if _, err := f.pw.Write(bytes.Repeat([]byte("x"), 4096)); err != nil {
t.Fatalf("writing the long line: %v", err)
}
_ = f.pw.Close() // the line ends, too long: the host must fail rather than grow it
waitFor(t, "the host failed on a too-long line", func() bool {
select {
case <-f.host.dead:
return true
default:
return false
}
})
}
// TestHostFailClearsIncompleteBodies: a host that fails lets go of an
// incomplete body's buffer and slot at once, so a call nothing reads or closes
// cannot keep the budget. A body whose answer already came is left for its
// reader.
func TestHostFailClearsIncompleteBodies(t *testing.T) {
t.Run("incomplete", func(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
if !c.push(message{Event: "head"}) {
t.Fatal("the head was refused")
}
if !c.push(message{Event: "chunk", Data: base64.StdEncoding.EncodeToString([]byte("hi"))}) {
t.Fatal("the chunk was refused")
}
if f.slotsFree() == cap(f.host.slots) {
t.Fatal("the call holds no slot before the host fails")
}
f.host.fail(errHostGone)
waitFor(t, "the incomplete body was let go", func() bool {
return c.queuedChunks() == 0 && f.slotsFree() == cap(f.host.slots)
})
if f.registered(id) {
t.Fatal("the failed host still holds the call")
}
})
t.Run("finished keeps its data", func(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
reply(t, f, id, c, func() {
f.chunk(id, "hi")
f.answer(id, "")
})
waitFor(t, "the answered call left the host", func() bool { return !f.registered(id) })
f.host.fail(errHostGone)
got, err := io.ReadAll(b)
if string(got) != "hi" || err != nil {
t.Fatalf("body = %q, %v, want the queued chunk hi and no error", got, err)
}
})
}
func TestHostShutdownPreservesDeliveredAnswer(t *testing.T) {
for _, why := range []string{"", "vendor rejected the request"} {
t.Run(fmt.Sprintf("error=%q", why), func(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
f.host.dispatch(message{ID: id, Event: "head"})
f.next(t, c)
f.host.dispatch(message{ID: id, Event: "chunk", Data: base64.StdEncoding.EncodeToString([]byte("hi"))})
answer := message{ID: id}
if why != "" {
answer = errAnswer(why)
answer.ID = id
}
f.host.dispatch(answer)
if c.state != callDraining || len(f.slots) != 1 {
t.Fatal("terminal did not retain draining capacity")
}
f.host.fail(errHostGone)
if c.state != callDraining || c.result().Error != answer.Error || c.queuedChunks() != 1 || len(f.slots) != 1 {
t.Fatal("shutdown changed the committed terminal, queue or capacity")
}
got, err := io.ReadAll(b)
if string(got) != "hi" {
t.Fatalf("body = %q, want hi", got)
}
if why == "" && err != nil || why != "" && (err == nil || err.Error() != why) {
t.Fatalf("terminal error = %v, want %q", err, why)
}
if c.state != callReleased || len(f.slots) != 0 {
t.Fatal("draining did not return capacity")
}
})
}
}
func TestHostShutdownWithoutAnswerEndsRead(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
f.host.fail(errHostGone)
<-c.done
errs := make(chan error, 1)
go func() {
_, err := b.Read(make([]byte, 8))
errs <- err
}()
select {
case err := <-errs:
if !errors.Is(err, errHostGone) {
t.Fatalf("read = %v, want errHostGone", err)
}
case <-time.After(5 * time.Second):
t.Fatal("a read without an answer didn't end after shutdown")
}
}
// TestHostStreamBoundsTheHeadEnvelope: a head's headers are capped by magpie's
// own envelope — entries and serialized bytes — so a vendor's unbounded header
// set is not a free zero-charge line. A head just under both is taken.
func TestHostStreamBoundsTheHeadEnvelope(t *testing.T) {
headers := func(n, size int) map[string]string {
h := map[string]string{}
for i := range n {
h[fmt.Sprintf("x-header-%03d", i)] = strings.Repeat("v", size)
}
return h
}
t.Run("legal", func(t *testing.T) {
f := newFakeHost(t)
_, c := f.begin(t, true)
if !c.push(message{Event: "head", Headers: headers(200, 64)}) {
t.Fatal("a head within the envelope was refused")
}
})
t.Run("too many entries", func(t *testing.T) {
f := newFakeHost(t)
_, c := f.begin(t, true)
if c.push(message{Event: "head", Headers: headers(maxHeadEntries+1, 1)}) {
t.Fatal("a head past the entry cap was taken")
}
if got := c.givenUp(); !errors.Is(got, errProtocolOverrun) {
t.Fatalf("givenUp = %v, want errProtocolOverrun", got)
}
})
t.Run("too many bytes", func(t *testing.T) {
f := newFakeHost(t)
_, c := f.begin(t, true)
if c.push(message{Event: "head", Headers: headers(maxHeadEntries, 512)}) {
t.Fatal("a head past the byte cap was taken")
}
if got := c.givenUp(); !errors.Is(got, errProtocolOverrun) {
t.Fatalf("givenUp = %v, want errProtocolOverrun", got)
}
})
}
// TestHostStreamHeadChunksFinal: a whole reply — head, chunks, final — is read
// back byte for byte, in order, and every delivered byte is credited back so
// the child may send more.
func TestHostStreamHeadChunksFinal(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
if h := reply(t, f, id, c, func() {
for i := range 50 {
f.chunk(id, chunkAt(i))
}
}); h.Status != 200 {
t.Fatalf("head = %+v", h)
}
want := chunks(50)
got := make([]byte, len(want))
if _, err := io.ReadFull(b, got); err != nil {
t.Fatalf("reading: %v", err)
}
if string(got) != want {
t.Fatalf("stream = %q, want %q", got, want)
}
// every delivered byte is credited back, plus a frame's overhead as each
// frame is done, so the child is never left short of what it sent
wantCredit := len(want) + 49*frameOverhead
waitFor(t, "the credits were given back", func() bool { return f.credited() == wantCredit })
if got := c.owedBytes(); got != frameOverhead {
t.Fatalf("%d bytes still owed after a full read", got)
}
f.answer(id, "")
if n, err := b.Read(make([]byte, 1)); n != 0 || err != io.EOF {
t.Fatalf("final read = %d, %v, want 0, EOF", n, err)
}
}
// TestHostStreamCreditBoundsTheQueue: a child that sends past the window it was
// given broke the protocol — the call is given up on, its queue dropped, and
// the child told to stop, once.
func TestHostStreamCreditBoundsTheQueue(t *testing.T) {
oldWindow := streamWindow
streamWindow = 8 + frameOverhead
t.Cleanup(func() { streamWindow = oldWindow })
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
reply(t, f, id, c, func() {})
// a chunk within the window is taken, one past it is not
if !c.push(message{Event: "chunk", Data: base64.StdEncoding.EncodeToString([]byte("12345678"))}) {
t.Fatal("a chunk within the window was refused")
}
if c.push(message{Event: "chunk", Data: base64.StdEncoding.EncodeToString([]byte("x"))}) {
t.Fatal("a chunk past the window was taken")
}
if got := c.givenUp(); !errors.Is(got, errProtocolOverrun) {
t.Fatalf("givenUp = %v, want errProtocolOverrun", got)
}
if n := c.queuedChunks(); n != 0 {
t.Fatalf("%d chunks left after a protocol overrun", n)
}
if _, err := io.ReadAll(b); !errors.Is(err, errProtocolOverrun) {
t.Fatalf("read = %v, want errProtocolOverrun", err)
}
waitFor(t, "the overrun was aborted once", func() bool {
got := f.methods()
return len(got) == 1 && got[0] == "abort"
})
// the call is the host's until its abort's write is known, which the
// child may read before the writer hears it went
waitFor(t, "the given-up call was released", func() bool { return !f.registered(id) })
}
// TestHostStreamCreditIsPerDeliveredByte: reading part of a chunk credits only
// what it took, so a reader that stops leaves the child paused with the rest.
func TestHostStreamCreditIsPerDeliveredByte(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
reply(t, f, id, c, func() {
f.chunk(id, "0123456789")
})
waitFor(t, "the chunk was queued", func() bool { return c.queuedChunks() == 1 })
if n, err := b.Read(make([]byte, 4)); n != 4 || err != nil {
t.Fatalf("read = %d, %v, want 4 bytes", n, err)
}
waitFor(t, "the 4 bytes were credited", func() bool { return f.credited() == 4 })
// the other six bytes and the frame's overhead are still owed: the child
// is paused until the rest is read
if got, want := c.owedBytes(), 10+frameOverhead-4; got != want {
t.Fatalf("owed = %d, want %d", got, want)
}
if n, err := b.Read(make([]byte, 6)); n != 6 || err != nil {
t.Fatalf("reading the rest: %d, %v", n, err)
}
waitFor(t, "the rest was credited", func() bool { return f.credited() >= 10 })
f.answer(id, "")
if _, err := io.ReadAll(b); err != nil {
t.Fatal(err)
}
}
// TestHostAdmissionLimitFailsTheNewCall: the host admits its budget of calls;
// the one past it fails clearly rather than a live reply being cut.
func TestHostAdmissionLimitFailsTheNewCall(t *testing.T) {
oldWindow, oldBudget := streamWindow, streamBudget
oldReserve := rpcReserve
streamWindow, streamBudget, rpcReserve = 1<<20, 2<<20, 0 // exactly two streaming calls
t.Cleanup(func() { streamWindow, streamBudget, rpcReserve = oldWindow, oldBudget, oldReserve })
f := newFakeHost(t)
for i := range 2 {
if _, _, err := f.host.begin(true); err != nil {
t.Fatalf("call %d: %v", i, err)
}
}
if _, _, err := f.host.begin(true); !errors.Is(err, errTooManyCalls) {
t.Fatalf("the call past the budget = %v, want errTooManyCalls", err)
}
// a slot back is a call admitted again
f.host.calls[1].release()
if _, _, err := f.host.begin(true); err != nil {
t.Fatalf("a call after one freed its slot: %v", err)
}
}
// TestHostCancelAfterHeadStopsTheFetch: a context that ends after the head,
// with nothing reading or closing the body, still stops the upstream fetch.
func TestHostCancelAfterHeadStopsTheFetch(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
ctx, cancel := context.WithCancel(context.Background())
b := readBody(f, id, c, ctx)
c.watchCtx(ctx)
reply(t, f, id, c, func() {})
cancel()
waitFor(t, "the fetch was aborted", func() bool { return !f.registered(id) })
if got := f.methods(); len(got) != 1 || got[0] != "abort" {
t.Fatalf("control = %v, want just an abort", got)
}
if _, err := b.Read(make([]byte, 8)); !errors.Is(err, context.Canceled) {
t.Fatalf("read = %v, want canceled", err)
}
if free := f.slotsFree(); free != cap(f.host.slots) {
t.Fatalf("%d slots free, want all %d", free, cap(f.host.slots))
}
}
// TestHostFetchNotSentNoAbort: a context that ends before the fetch reaches the
// writer leaves nothing to abort, and gives the slot straight back.
func TestHostFetchNotSentNoAbort(t *testing.T) {
f := newFakeHost(t)
_, c, err := f.host.begin(true)
if err != nil {
t.Fatal(err)
}
c.settle(context.Canceled)
waitFor(t, "the call left the host", func() bool { return len(f.host.calls) == 0 })
if got := f.methods(); len(got) != 0 {
t.Fatalf("control = %v, want nothing: the child never saw the fetch", got)
}
if free := f.slotsFree(); free != cap(f.host.slots) {
t.Fatalf("%d slots free, want all", free)
}
}
// TestHostAbortFollowsTheFetch: the fetch is written before the abort that
// follows it, so a cancelled call never starts an upstream it cannot stop.
func TestHostAbortFollowsTheFetch(t *testing.T) {
for _, fail := range []bool{false, true} {
t.Run(fmt.Sprintf("blocked-abort-fail=%t", fail), func(t *testing.T) {
h := failureTestHost(t)
w := &blockedAbortWriter{entered: make(chan struct{}), release: make(chan struct{}), methods: make(chan string, 4)}
h.in = w
writerDone := make(chan struct{})
go func() { h.writerLoop(); close(writerDone) }()
id, c, err := h.begin(true)
if err != nil {
t.Fatal(err)
}
if err := h.enqueueCall(context.Background(), map[string]any{"id": id, "method": "fetch"}, nil, c); err != nil {
t.Fatal(err)
}
c.settle(context.Canceled)
c.settle(errBodyClosed)
pumped := make(chan struct{})
go func() { h.pumpCtrl(); close(pumped) }()
<-w.entered
h.mu.Lock()
state, registered := c.state, h.calls[id] == c
h.mu.Unlock()
h.ctrlMu.Lock()
controls := len(h.ctrl)
h.ctrlMu.Unlock()
if state != callAborting || !registered || controls != 0 || len(h.slots) != 1 {
t.Fatal("in-flight abort lost ownership or returned capacity before acknowledgement")
}
if fail {
h.fail(errHostGone)
if len(h.slots) != 0 || len(h.streams) != 0 {
t.Fatal("host failure relied on pending control map membership")
}
}
close(w.release)
<-pumped
if len(h.slots) != 0 || len(h.streams) != 0 {
t.Fatal("abort acknowledgement retained capacity")
}
h.fail(errHostGone)
<-writerDone
if first, second := <-w.methods, <-w.methods; first != "fetch" || second != "abort" || len(w.methods) != 0 {
t.Fatalf("writes = %s, %s; want one fetch then one abort", first, second)
}
})
}
f := newFakeHost(t)
_, c, err := f.host.begin(true)
if err != nil {
t.Fatal(err)
}
if err := f.host.enqueueCall(context.Background(), map[string]any{"id": c.id, "method": "fetch", "params": map[string]any{}}, nil, c); err != nil {
t.Fatal(err)
}
c.settle(context.Canceled)
waitFor(t, "the abort was written", func() bool {
for _, m := range f.methods() {
if m == "abort" {
return true
}
}
return false
})
if got := f.methods(); len(got) != 2 || got[0] != "fetch" || got[1] != "abort" {
t.Fatalf("control = %v, want fetch then abort", got)
}
}
type blockedAbortWriter struct {
entered chan struct{}
release chan struct{}
methods chan string
}
func (w *blockedAbortWriter) Write(data []byte) (int, error) {
var frame struct {
Method string `json:"method"`
}
if err := json.Unmarshal(data, &frame); err != nil {
return 0, err
}
w.methods <- frame.Method
if frame.Method == "abort" {
close(w.entered)
<-w.release
}
return len(data), nil
}
func (w *blockedAbortWriter) Close() error { return nil }
// TestHostBlockedWriterHonorsCtx: when the child's stdin queue is full, an
// enqueue gives up on its context instead of parking forever.
func TestHostBlockedWriterHonorsCtx(t *testing.T) {
h := &host{out: make(chan writeReq, 1), dead: make(chan struct{})}
h.out <- writeReq{b: []byte("{}\n")} // the queue is full, and no writer is draining it
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- h.enqueue(ctx, map[string]any{"id": 1}) }()
select {
case err := <-done:
t.Fatalf("enqueue returned before cancel: %v", err)
case <-time.After(30 * time.Millisecond):
}
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("enqueue = %v, want canceled", err)
}
case <-time.After(5 * time.Second):
t.Fatal("a full stdin queue held the caller past its cancel")
}
}
// TestHostFinalBeforeDrainHoldsTheSlot: the final answer does not mean the body
// is consumed — the call keeps its slot until the body reaches EOF or Close.
func TestHostFinalBeforeDrainHoldsTheSlot(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
reply(t, f, id, c, func() {
for i := range 20 {
f.chunk(id, chunkAt(i))
}
f.answer(id, "")
})
waitFor(t, "the answered call left the host's hands", func() bool { return !f.registered(id) })
if free := f.slotsFree(); free == cap(f.host.slots) {
t.Fatalf("the slot came back before the body was read")
}
if _, err := io.ReadAll(b); err != nil {
t.Fatalf("reading: %v", err)
}
waitFor(t, "the slot came back at EOF", func() bool { return f.slotsFree() == cap(f.host.slots) })
}
// TestHostStreamKeepsChunkOrderAndAnswer: chunks before an answer that failed
// are still read, in order, then the error.
func TestHostStreamKeepsChunkOrderAndAnswer(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
reply(t, f, id, c, func() {
f.chunk(id, "one")
f.chunk(id, "two")
f.answer(id, "the vendor exploded")
})
got, err := io.ReadAll(b)
if err == nil || !strings.Contains(err.Error(), "the vendor exploded") {
t.Fatalf("error = %v, want the plugin's", err)
}
if string(got) != "onetwo" {
t.Fatalf("stream = %q, want %q", got, "onetwo")
}
}
// TestHostBodyCancelEndsAStalledRead: ctx ending must end a read waiting on the
// plugin, and give the call up with it.
func TestHostBodyCancelEndsAStalledRead(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
ctx, cancel := context.WithCancel(context.Background())
b := readBody(f, id, c, ctx)
reply(t, f, id, c, func() {}) // the head: no chunks ever come
c.watchCtx(ctx)
errs := make(chan error, 1)
go func() {
_, err := b.Read(make([]byte, 8))
errs <- err
}()
select {
case err := <-errs:
t.Fatalf("the read ended before cancel: %v", err)
case <-time.After(30 * time.Millisecond):
}
cancel()
select {
case err := <-errs:
if !errors.Is(err, context.Canceled) {
t.Fatalf("read = %v, want canceled", err)
}
case <-time.After(5 * time.Second):
t.Fatal("cancel didn't end a read waiting on the plugin")
}
waitFor(t, "the abort was acknowledged after cancel", func() bool { return !f.registered(id) })
}
// TestHostBodyCloseEndsAnIdleRead: closing a body nothing was ever queued for
// must end a read parked on it, and give the call up.
func TestHostBodyCloseEndsAnIdleRead(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
reply(t, f, id, c, func() {})
errs := make(chan error, 1)
go func() {
_, err := b.Read(make([]byte, 8))
errs <- err
}()
time.Sleep(20 * time.Millisecond)
if err := b.Close(); err != nil {
t.Fatalf("close: %v", err)
}
select {
case err := <-errs:
if !errors.Is(err, errBodyClosed) {
t.Fatalf("a read parked at close = %v, want errBodyClosed", err)
}
case <-time.After(5 * time.Second):
t.Fatal("closing the body didn't end a read parked on it")
}
if _, err := b.Read(make([]byte, 8)); !errors.Is(err, errBodyClosed) {
t.Fatalf("a read after close = %v, want errBodyClosed", err)
}
waitFor(t, "the call left the host", func() bool { return !f.registered(id) })
}
// TestHostPushAfterCloseIsNotQueued: a chunk the reader is already gone for is
// not queued again, whatever dispatch still holds.
func TestHostPushAfterCloseIsNotQueued(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
reply(t, f, id, c, func() {})
if err := b.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if c.push(message{Event: "chunk", Data: base64.StdEncoding.EncodeToString([]byte("hi"))}) {
t.Fatal("a chunk was queued for a body already given up on")
}
if n := c.queuedChunks(); n != 0 {
t.Fatalf("%d chunks queued for a closed body", n)
}
}
// TestHostBodyZeroLengthRead: a read that asks for nothing reads nothing, and
// takes nothing from the call.
func TestHostBodyZeroLengthRead(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
reply(t, f, id, c, func() {
f.chunk(id, "hi")
f.answer(id, "")
})
waitFor(t, "the chunk was queued", func() bool { return c.queuedChunks() == 1 })
if n, err := b.Read(nil); n != 0 || err != nil {
t.Fatalf("zero-length read = %d, %v, want 0, nil", n, err)
}
if n := c.queuedChunks(); n != 1 {
t.Fatalf("a zero-length read took a chunk: %d left", n)
}
got, err := io.ReadAll(b)
if err != nil || string(got) != "hi" {
t.Fatalf("body = %q, %v, want hi", got, err)
}
}
// TestHostShutdownEndsAStalledRead: the host quitting answers its pending call
// with why, so a read waiting on it ends rather than waiting for ever.
func TestHostShutdownEndsAStalledRead(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
reply(t, f, id, c, func() {})
errs := make(chan error, 1)
go func() {
_, err := b.Read(make([]byte, 8))
errs <- err
}()
time.Sleep(20 * time.Millisecond)
_ = f.pw.CloseWithError(io.EOF) // the child's stdout ends: it quit
select {
case err := <-errs:
if err == nil || (!errors.Is(err, errHostGone) && !strings.Contains(err.Error(), "plugin host quit")) {
t.Fatalf("read = %v, want the host's", err)
}
case <-time.After(5 * time.Second):
t.Fatal("the host quitting didn't end a read waiting on it")
}
}
// TestHostHeadKeepsTheAnswerWhenItCameWithTheHead: a head read can find the
// call's answer already there and take the head out of the queue with it. The
// answer must reach the body all the same, an error staying an error.
func TestHostHeadKeepsTheAnswerWhenItCameWithTheHead(t *testing.T) {
for _, tc := range []struct {
name string
why string
}{
{"answered", ""},
{"failed", "the vendor exploded"},
} {
t.Run(tc.name, func(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
type read struct {
head message
answer *message
err error
}
ctx := context.Background()
out := make(chan read, 1)
go func() {
h, a, err := c.headRead(ctx)
out <- read{h, a, err}
}()
// the window the race makes: the answer is there, and a head is
// queued with no wake for it
time.Sleep(20 * time.Millisecond)
c.mu.Lock()
c.queue = append(c.queue, message{Status: 200})
c.mu.Unlock()
c.closed.Store(true)
if tc.why == "" {
c.done <- message{}
} else {
c.done <- errAnswer(tc.why)
}
r := <-out
if r.err != nil || r.head.Status != 200 {
t.Fatalf("head = %+v, %v", r.head, r.err)
}
if r.answer == nil {
t.Fatal("the answer was lost: the body would never end")
}
b := &body{h: f.host, id: id, c: c, ctx: ctx, answer: r.answer, closed: make(chan struct{})}
got, err := io.ReadAll(b)
if len(got) != 0 {
t.Fatalf("body = %q, want nothing", got)
}
switch {
case tc.why == "":
if err != nil {
t.Fatalf("body ended with %v, want a clean end", err)
}
case err == nil || !strings.Contains(err.Error(), tc.why):
t.Fatalf("body ended with %v, want the plugin's %q", err, tc.why)
}
})
}
}
// TestHostBodyReadStopsAtCloseWithBufferedData: what is left of a chunk, and
// what is queued behind it, is not read after the body is closed.
func TestHostBodyReadStopsAtCloseWithBufferedData(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
reply(t, f, id, c, func() {
f.chunk(id, "first")
f.chunk(id, "second")
f.answer(id, "")
})
if n, err := b.Read(make([]byte, 1)); n != 1 || err != nil {
t.Fatalf("read = %d, %v, want one byte", n, err)
}
if err := b.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if n, err := b.Read(make([]byte, 16)); n != 0 || !errors.Is(err, errBodyClosed) {
t.Fatalf("read after close = %d, %v, want 0, errBodyClosed", n, err)
}
}
// TestHostBodyCloseDuringRead: a close under a read ends it, and leaves no race
// on what the read holds.
type blockedDoneContext struct {
context.Context
entered chan struct{}
release chan struct{}
}
func (ctx *blockedDoneContext) Done() <-chan struct{} {
close(ctx.entered)
<-ctx.release
return nil
}
func TestHostBodyCloseDuringRead(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
ctx := &blockedDoneContext{Context: context.Background(), entered: make(chan struct{}), release: make(chan struct{})}
b := readBody(f, id, c, ctx)
done := make(chan error, 1)
go func() {
_, err := b.Read(make([]byte, 8))
done <- err
}()
<-ctx.entered
_ = b.Close()
close(ctx.release)
select {
case err := <-done:
if !errors.Is(err, errBodyClosed) {
t.Fatalf("read = %v, want errBodyClosed", err)
}
case <-time.After(5 * time.Second):
t.Fatal("a read didn't end after the body closed")
}
waitFor(t, "exactly one abort acknowledged", func() bool {
return len(f.slots) == 0 && len(f.methods()) == 1 && f.methods()[0] == "abort"
})
}
// TestHostAbortIsQueuedOncePerCall: whoever takes the call out of the host's
// hands tells the child, so a call is aborted once however many of its paths
// get there — and an answered one not at all.
func TestHostAbortIsQueuedOncePerCall(t *testing.T) {
t.Run("overrun then close", func(t *testing.T) {
oldWindow := streamWindow
streamWindow = 8
t.Cleanup(func() { streamWindow = oldWindow })
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
reply(t, f, id, c, func() {})
c.push(message{Event: "chunk", Data: base64.StdEncoding.EncodeToString([]byte("12345678"))})
c.push(message{Event: "chunk", Data: base64.StdEncoding.EncodeToString([]byte("x"))}) // past the window
if _, err := io.ReadAll(b); !errors.Is(err, errProtocolOverrun) {
t.Fatalf("read = %v, want the protocol overrun", err)
}
if err := b.Close(); err != nil {
t.Fatalf("close: %v", err)
}
waitFor(t, "the call was aborted once", func() bool {
got := f.methods()
return len(got) == 1 && got[0] == "abort"
})
})
t.Run("answered then close", func(t *testing.T) {
f := newFakeHost(t)
id, c := f.begin(t, true)
b := readBody(f, id, c, context.Background())
reply(t, f, id, c, func() {
f.chunk(id, "hi")
f.answer(id, "")
})
if got, err := io.ReadAll(b); err != nil || string(got) != "hi" {
t.Fatalf("body = %q, %v", got, err)
}
if f.registered(id) {
t.Fatal("an answered call is still the host's")
}
if err := b.Close(); err != nil {
t.Fatalf("close: %v", err)
}
for _, m := range f.methods() {
if m == "abort" {
t.Fatal("an answered call was aborted")
}
}
})
}
// TestHostStreamRefusesBadFrames: a chunk that is not base64 of one to maxFrame
// bytes — empty, malformed or oversized — is refused before it is queued, so it
// cannot spend the window wrong or flood the queue. A well-formed frame of any
// padding is taken.
func TestHostStreamRefusesBadFrames(t *testing.T) {
for _, data := range []string{"YQ==", "YWI=", "YWJj", "YWJjZA=="} {
f := newFakeHost(t)
_, c := f.begin(t, true)
if !c.push(message{Event: "chunk", Data: data}) {
t.Fatalf("the frame %q was refused", data)
}
}
for _, tc := range []struct{ name, data string }{
{"empty", ""},
{"not base64", "Y!Jj"},
{"not a whole group", "YQ="},
{"padding in the middle", "YQ==YQ=="},
{"oversized", base64.StdEncoding.EncodeToString(make([]byte, maxFrame+1))},
} {
t.Run(tc.name, func(t *testing.T) {
f := newFakeHost(t)
_, c := f.begin(t, true)
if c.push(message{Event: "chunk", Data: tc.data}) {
t.Fatal("a bad frame was queued")
}
if got := c.givenUp(); !errors.Is(got, errProtocolOverrun) {
t.Fatalf("givenUp = %v, want errProtocolOverrun", got)
}
if n := c.queuedChunks(); n != 0 {
t.Fatalf("%d chunks queued for a bad frame", n)
}
})
}
}
// TestHostStreamFrameOverheadBoundsTheQueue: tiny frames cannot fill a window
// with metadata — each costs its bytes plus frameOverhead, so a window of N
// frame costs holds at most N of them, whatever their size.
func TestHostStreamFrameOverheadBoundsTheQueue(t *testing.T) {
oldWindow := streamWindow
streamWindow = 8 * (1 + frameOverhead) // room for exactly eight 1-byte frames
t.Cleanup(func() { streamWindow = oldWindow })
f := newFakeHost(t)
_, c := f.begin(t, true)
for i := range 8 {
if !c.push(message{Event: "chunk", Data: "YQ=="}) { // one byte
t.Fatalf("frame %d was refused within the window", i)
}
}
if c.push(message{Event: "chunk", Data: "YQ=="}) {
t.Fatal("a ninth tiny frame fit a window of eight frame costs")
}
// a child that sends past its window broke the protocol: the queue is
// dropped rather than grown, and the call is given up on
if got := c.givenUp(); !errors.Is(got, errProtocolOverrun) {
t.Fatalf("givenUp = %v, want errProtocolOverrun", got)
}
if got := c.queuedChunks(); got != 0 {
t.Fatalf("%d frames left after a protocol overrun", got)
}
}
// errAnswer is the answer a plugin's fetch that failed gives.
func errAnswer(why string) message {
return message{Error: &struct {
Message string `json:"message"`
}{why}}
}
var _ = base64.StdEncoding
func failureTestHost(t *testing.T) *host {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
h := &host{
in: discardCloser{}, calls: map[int64]*call{}, dead: make(chan struct{}),
slots: make(chan struct{}, callLimit()), streams: make(chan struct{}, streamLimit()),
out: make(chan writeReq, callLimit()), ctrl: map[int64]*ctrl{},
ctrlWake: make(chan struct{}, 1), ctx: ctx, cancel: cancel,
}
t.Cleanup(func() { h.fail(errHostGone) })
return h
}
func TestHostFailureRejectsLateWork(t *testing.T) {
t.Run("admission", func(t *testing.T) {
h := failureTestHost(t)
h.fail(errHostGone)
for _, stream := range []bool{false, true} {
id, c, err := h.begin(stream)
if !errors.Is(err, errHostGone) || id != 0 || c != nil {
t.Fatalf("begin(%t) = %d, %v, %v, want no call and errHostGone", stream, id, c, err)
}
}
if len(h.slots) != 0 || len(h.streams) != 0 || len(h.calls) != 0 {
t.Fatal("failed host retained admission")
}
})
t.Run("enqueue", func(t *testing.T) {
h := failureTestHost(t)
h.fail(errHostGone)
for range 100 {
if err := h.enqueue(context.Background(), map[string]any{"body": "late payload"}); !errors.Is(err, errHostGone) {
t.Fatalf("enqueue = %v, want errHostGone", err)
}
}
if len(h.out) != 0 {
t.Fatalf("failed host retained %d writes", len(h.out))
}
})
t.Run("control", func(t *testing.T) {
h := failureTestHost(t)
_, c, err := h.begin(true)
if err != nil {
t.Fatal(err)
}
c.transition(callAccepted, nil, nil)
h.abort(c)
h.fail(errHostGone)
h.abort(c)
h.credit(c, 1)
if len(h.ctrl) != 0 || len(h.slots) != 0 || len(h.streams) != 0 {
t.Fatalf("failed host retained controls=%d slots=%d streams=%d", len(h.ctrl), len(h.slots), len(h.streams))
}
})
}
func TestHostFailureRacesAdmissionAndCancel(t *testing.T) {
for _, order := range []string{"failure-before-admission", "cancellation-before-enqueue", "enqueue-before-cancellation", "terminal-before-failure", "failure-before-terminal"} {
t.Run(order, func(t *testing.T) {
h := failureTestHost(t)
if order == "failure-before-admission" {
h.fail(errHostGone)
if _, _, err := h.begin(true); !errors.Is(err, errHostGone) {
t.Fatal(err)
}
return
}
id, c, err := h.begin(true)
if err != nil {
t.Fatal(err)
}
fetch := map[string]any{"id": id, "method": "fetch"}
if order == "cancellation-before-enqueue" {
c.settle(context.Canceled)
if err := h.enqueueCall(context.Background(), fetch, nil, c); !errors.Is(err, context.Canceled) {
t.Fatalf("late enqueue: %v", err)
}
if len(h.out) != 0 || len(h.ctrl) != 0 || len(h.slots) != 0 {
t.Fatal("unsent fetch retained work")
}
return
}
if err := h.enqueueCall(context.Background(), fetch, nil, c); err != nil {
t.Fatal(err)
}
switch order {
case "enqueue-before-cancellation":
c.settle(context.Canceled)
c.settle(errBodyClosed)
if c.state != callAborting || len(h.slots) != 1 || len(h.ctrl) != 1 {
t.Fatal("accepted cancellation did not retain one abort")
}
h.fail(errHostGone)
if !errors.Is(c.givenUp(), context.Canceled) {
t.Fatal("failure replaced first outcome")
}
case "terminal-before-failure":
h.dispatch(message{ID: id, Result: json.RawMessage(`null`)})
terminal := c.result()
h.fail(errHostGone)
c.transition(callHostFailed, errHostGone, nil)
if c.state != callDraining || c.result() != terminal || len(h.slots) != 1 {
t.Fatal("failure replaced terminal or returned capacity")
}
c.release()
case "failure-before-terminal":
h.fail(errHostGone)
h.dispatch(message{ID: id, Result: json.RawMessage(`null`)})
if !errors.Is(c.givenUp(), errHostGone) || c.result().Error == nil {
t.Fatal("late answer replaced host failure")
}
}
if c.state != callReleased || len(h.slots) != 0 || len(h.streams) != 0 {
t.Fatal("lifecycle did not return capacity")
}
})
}
for iteration := range 200 {
h := failureTestHost(t)
start := make(chan struct{})
finished := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
go func() {
defer close(finished)
<-start
id, c, err := h.begin(true)
if err != nil {
return
}
c.watchCtx(ctx)
_ = h.enqueueCall(ctx, map[string]any{"id": id, "method": "fetch", "params": map[string]any{"body": "late payload"}}, nil, c)
c.settle(context.Canceled)
h.credit(c, 1)
}()
close(start)
cancel()
h.fail(errHostGone)
select {
case <-finished:
case <-time.After(2 * time.Second):
t.Fatalf("iteration %d: cancelled fetch did not return", iteration)
}
if len(h.slots) != 0 || len(h.streams) != 0 || len(h.calls) != 0 || len(h.ctrl) != 0 || len(h.out) != 0 {
t.Fatalf("iteration %d: slots=%d streams=%d calls=%d controls=%d writes=%d", iteration, len(h.slots), len(h.streams), len(h.calls), len(h.ctrl), len(h.out))
}
}
}
func TestHostEnqueueWaitsForRoom(t *testing.T) {
t.Run("cancel-unsent-owner", func(t *testing.T) {
h := failureTestHost(t)
h.out = make(chan writeReq, 1)
h.out <- writeReq{b: []byte("{}\n")}
id, c, err := h.begin(true)
if err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
result <- h.enqueueCall(context.Background(), map[string]any{"id": id, "method": "fetch"}, nil, c)
}()
waitFor(t, "unsent fetch waiting for writer room", func() bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.outSpace != nil
})
c.settle(context.Canceled)
<-c.wake
select {
case err := <-result:
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("writer waiter depended on the reader's notification")
}
if len(h.out) != 1 || len(h.slots) != 0 || len(h.ctrl) != 0 {
t.Fatal("cancelled unsent fetch retained admission or queued work")
}
})
for _, fail := range []bool{false, true} {
t.Run(fmt.Sprintf("fail=%t", fail), func(t *testing.T) {
h := failureTestHost(t)
h.out = make(chan writeReq, 1)
if err := h.enqueue(context.Background(), map[string]any{"id": 1}); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() { result <- h.enqueue(context.Background(), map[string]any{"id": 2}) }()
waitFor(t, "enqueue waiting for room", func() bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.outSpace != nil
})
if fail {
h.fail(errHostGone)
} else {
go h.writerLoop()
}
select {
case err := <-result:
if fail && !errors.Is(err, errHostGone) || !fail && err != nil {
t.Fatalf("enqueue = %v, fail=%t", err, fail)
}
case <-time.After(2 * time.Second):
t.Fatal("enqueue did not wake after room or failure")
}
})
}
}