Files
Maven/internal/ipc/cancel_test.go
T
claude 123b9aa961 an ipc call can be cancelled and always has a deadline (V-638)
roundtrip set no connection deadline and call checked the context once,
before sending, so a daemon that read the frame and stopped answering
parked the caller for as long as the socket stayed open.

The deadline comes from the caller's context, falling back to 120s, the
convention internal/voice/client.go already had. A watchdog closes the
conn on ctx.Done(); it closes rather than calling drop, because drop
wants c.mu and call is holding it.

The retry split is unchanged and now also declines a retry the caller
has stopped waiting for. A cancelled mutation stays ErrAmbiguousOutcome,
because it may have committed. A cancelled read reports the cancellation.

Three tests against a server that accepts and never answers. There was
no test for this, which is why it went unnoticed.
2026-08-06 22:54:19 +04:00

120 lines
3.0 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)
}
}