69d0f5ee78
Co-authored-by: claude <no-reply@agents.claude.kvmx.ru> Co-committed-by: claude <no-reply@agents.claude.kvmx.ru>
193 lines
5.2 KiB
Go
193 lines
5.2 KiB
Go
package ipc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// A cancelled context has to abort a call that is already in flight. It did not
|
|
// until V-638: call checked ctx once before sending and then blocked in
|
|
// roundtrip with no connection deadline, so a daemon that read the frame and
|
|
// never answered parked the caller for as long as the socket stayed open.
|
|
//
|
|
// The server here is that daemon: it accepts, reads nothing, replies nothing.
|
|
|
|
func deafServer(t *testing.T) string {
|
|
t.Helper()
|
|
sock := filepath.Join(t.TempDir(), "deaf.sock")
|
|
ln, err := net.Listen("unix", sock)
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
// Hold it open and say nothing. Closed by the listener cleanup.
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
}
|
|
}()
|
|
return sock
|
|
}
|
|
|
|
func TestClientCancelAbortsAReadInFlight(t *testing.T) {
|
|
c, err := Dial(deafServer(t))
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
time.Sleep(50 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := c.Ping(ctx)
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
// Ping is read-only, so the cancellation is reported as itself rather
|
|
// than as an ambiguous mutation.
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("got %v, want context.Canceled", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("a cancelled Ping did not return")
|
|
}
|
|
}
|
|
|
|
// A mutation cancelled while awaiting the reply may already have committed, so
|
|
// it is ErrAmbiguousOutcome and never a retry. That split is the invariant
|
|
// internal/ipc/maperr_test.go's neighbours rest on.
|
|
func TestClientCancelLeavesAMutationAmbiguous(t *testing.T) {
|
|
c, err := Dial(deafServer(t))
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := c.WriteFact(ctx, WriteFactReq{Key: "water", Value: "drank"})
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, ErrAmbiguousOutcome) {
|
|
t.Errorf("got %v, want ErrAmbiguousOutcome", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("a cancelled WriteFact did not return")
|
|
}
|
|
}
|
|
|
|
// The deadline itself, with no cancellation: a call on a context with no
|
|
// deadline used to have no bound at all. This one has one and must respect it.
|
|
func TestClientDeadlineBoundsACall(t *testing.T) {
|
|
c, err := Dial(deafServer(t))
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
if _, err := c.Ping(ctx); err == nil {
|
|
t.Fatal("a deaf server answered a Ping")
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
|
t.Errorf("Ping took %v, want the context deadline to bound it", elapsed)
|
|
}
|
|
}
|
|
|
|
// blockingAPI parks Presence until its context is cancelled and records what
|
|
// cancelled it. Every other method is the unimplemented floor.
|
|
type blockingAPI struct {
|
|
UnimplementedCoreAPI
|
|
entered chan struct{}
|
|
err chan error
|
|
}
|
|
|
|
func (b *blockingAPI) Presence(ctx context.Context) (Presence, error) {
|
|
close(b.entered)
|
|
<-ctx.Done()
|
|
b.err <- ctx.Err()
|
|
return Presence{}, ctx.Err()
|
|
}
|
|
|
|
// serveConn dispatched under context.Background() until V-638, so Close could
|
|
// only abandon a dispatch in flight and never tell it to stop.
|
|
func TestServerCloseCancelsADispatchInFlight(t *testing.T) {
|
|
api := &blockingAPI{entered: make(chan struct{}), err: make(chan error, 1)}
|
|
srv, err := Listen(filepath.Join(t.TempDir(), "core.sock"), api)
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
served := make(chan struct{})
|
|
go func() { _ = srv.Serve(); close(served) }()
|
|
|
|
cli, err := Dial(srv.Path())
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer cli.Close()
|
|
go func() { _, _ = cli.Presence(context.Background()) }()
|
|
|
|
select {
|
|
case <-api.entered:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the handler was never dispatched")
|
|
}
|
|
|
|
_ = srv.Close()
|
|
<-served
|
|
select {
|
|
case got := <-api.err:
|
|
if !errors.Is(got, context.Canceled) {
|
|
t.Errorf("handler saw %v, want context.Canceled", got)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("Close did not cancel the dispatch")
|
|
}
|
|
}
|
|
|
|
// The watchdog closes the conn, and it races the end of the call: a
|
|
// cancellation landing as the reply arrives can close a conn the call was
|
|
// already done with. That is survivable either way, because a write to a closed
|
|
// socket is errWriteLost and errWriteLost re-dials and retries, so this test
|
|
// passes with or without the drop in roundtrip's defer. What it pins is that
|
|
// the recovery is real and costs one round trip at most, never an error the
|
|
// caller sees.
|
|
func TestClientSurvivesACancelledCall(t *testing.T) {
|
|
_, _, cli, _ := newServerWithStore(t)
|
|
|
|
for i := 0; i < 20; i++ {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go cancel() // races the reply on purpose
|
|
_, _ = cli.Ping(ctx)
|
|
cancel()
|
|
|
|
if _, err := cli.Ping(context.Background()); err != nil {
|
|
t.Fatalf("call %d after a cancelled one: %v", i, err)
|
|
}
|
|
}
|
|
}
|