shutdown: close the sockets, or the database never gets sealed

mavend seals its encrypted database in `defer st.Close()` when run() returns.
It had not returned since 2026-07-21. Every restart since then decrypted the
same eleven-day-old ciphertext and rolled back everything written in between:
the Telegram nudge that kept firing was a fact being un-written on each boot.

The goroutine dump named it. main → srv.Close() → ipc.(*Server).Close →
wg.Wait(), waiting on per-connection goroutines parked in readFrame. Close
shut the listener and nothing else, so the idle persistent sockets held by
mavweb, mavpoll, mavcaldav and mavmaild blocked shutdown forever. `docker
compose stop -t 60` spent the whole sixty seconds and then took a SIGKILL.

So: track the accepted conns and close them, in ipc and in voice, which had
the identical defect. Bound all three waits — the two per-server ones and the
worker wait in main — because the seal matters more than any single in-flight
call. A dropped RPC costs one reply; a missed seal costs a session.

The regression test leaves a client connected and idle, which is the case the
old tests avoided by closing the client first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
kami
2026-08-01 20:05:52 +04:00
parent 79893d646b
commit f1a809121b
9 changed files with 657 additions and 4 deletions
+27
View File
@@ -640,3 +640,30 @@ func TestSwapModel_Hook(t *testing.T) {
t.Fatalf("swap to a non-allowlisted path = %v; want ErrForbidden", err)
}
}
// The eleven-day bug: Close shut the listener but not the accepted conns, so
// an idle client left serveConn parked in readFrame and wg.Wait never
// returned. mavend deadlocked before `defer st.Close()` could re-encrypt the
// database, and every write since the last clean stop was rolled back on the
// next boot. The client here stays connected and idle on purpose.
func TestCloseReturnsWithAnIdleClientConnected(t *testing.T) {
_, srv, cli, _ := newServerWithStore(t)
// Prove the conn is live and then leave it alone — no cli.Close().
if _, err := cli.RecentFacts(context.Background(), 1); err != nil {
t.Fatalf("warm-up call: %v", err)
}
done := make(chan error, 1)
go func() { done <- srv.Close() }()
select {
case err := <-done:
if err != nil {
t.Fatalf("close: %v", err)
}
// Deliberately shorter than closeGrace: the grace timer is the backstop,
// not the mechanism. Closing the conns is what makes readFrame return, and
// if that regresses this waits out the full grace and fails here.
case <-time.After(closeGrace / 2):
t.Fatal("Close blocked on an idle connection — the shutdown deadlock is back")
}
}