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:
kami
2026-07-20 01:12:10 +04:00
parent d9fa4d6613
commit 838fde1fff
2 changed files with 159 additions and 22 deletions
+93
View File
@@ -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 {