Fail closed on ambiguous IPC mutation outcomes instead of blind retry
Vikunja #269 (P0): Client.call() retried any connection-loss uniformly, including the case where the request frame was already sent and the reply never arrived — the server may have already committed the write before dying, so a blind retry could double-apply it. This violates the ecosystem rule against retrying an unknown mutation outcome. - Split errConnLost into errWriteLost (request never sent — always safe to retry) and errReadLost (request sent, reply lost — ambiguous). - errReadLost is only auto-retried for read-only methods (replaying a read can't double-apply). A mutation method instead returns ErrAmbiguousOutcome so the caller can decide, rather than the boundary silently guessing. - Added commit-then-disconnect regression tests: a mutation (WriteFact) surfaces ErrAmbiguousOutcome and does not retry; a read (Presence) retries transparently past the same disconnect timing.
This commit is contained in:
@@ -542,6 +542,99 @@ func dialRaw(path string) (net.Conn, error) {
|
||||
return net.Dial("unix", path)
|
||||
}
|
||||
|
||||
// crashAfterReceiveServer accepts exactly one connection, reads exactly one
|
||||
// request frame (so, from the client's point of view, the request definitely
|
||||
// reached the server — a real write could have already committed at this
|
||||
// point), then closes the connection without ever writing a reply. This is
|
||||
// the "commit-then-disconnect" scenario the audit finding is about: the
|
||||
// client cannot tell success from failure from the dropped connection alone.
|
||||
func crashAfterReceiveServer(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
l, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = l.Close() })
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var req Request
|
||||
_ = readFrame(conn, &req)
|
||||
_ = conn.Close() // crash: request received, no reply ever sent
|
||||
}()
|
||||
}
|
||||
|
||||
// TestClient_MutationNotRetriedOnAmbiguousDisconnect — the audit's core
|
||||
// finding (Vikunja #269): a write whose reply never arrived (server received
|
||||
// the frame, then died before replying) must not be silently retried, since
|
||||
// the original request may have already committed. The client must surface
|
||||
// ErrAmbiguousOutcome instead of guessing either way.
|
||||
func TestClient_MutationNotRetriedOnAmbiguousDisconnect(t *testing.T) {
|
||||
sock := tmpSocket(t)
|
||||
crashAfterReceiveServer(t, sock)
|
||||
|
||||
cli, err := Dial(sock)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
_, err = cli.WriteFact(context.Background(), WriteFactReq{Key: "k", Value: "v", Source: "test"})
|
||||
if !errors.Is(err, ErrAmbiguousOutcome) {
|
||||
t.Fatalf("expected ErrAmbiguousOutcome on commit-then-disconnect, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_ReadRetriedOnAmbiguousDisconnect — the same disconnect timing on
|
||||
// a read-only method is safe to retry (replaying a read can't double-apply):
|
||||
// the first connection receives the request and dies without replying, the
|
||||
// second connection (the client's automatic retry redial) gets a real reply.
|
||||
// No sleeps: each accepted connection is handled deterministically by index.
|
||||
func TestClient_ReadRetriedOnAmbiguousDisconnect(t *testing.T) {
|
||||
sock := tmpSocket(t)
|
||||
|
||||
l, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = l.Close() })
|
||||
|
||||
var n int
|
||||
go func() {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
n++
|
||||
attempt := n
|
||||
go func(conn net.Conn, attempt int) {
|
||||
defer conn.Close()
|
||||
var req Request
|
||||
if err := readFrame(conn, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if attempt == 1 {
|
||||
return // crash: request received, no reply ever sent
|
||||
}
|
||||
_ = writeFrame(conn, Response{Result: mustJSON(Presence{})})
|
||||
}(conn, attempt)
|
||||
}
|
||||
}()
|
||||
|
||||
cli, err := Dial(sock)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
if _, err := cli.Presence(context.Background()); err != nil {
|
||||
t.Fatalf("read should have retried past the ambiguous disconnect, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user