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
-1
View File
@@ -508,7 +508,6 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
// model's own answer than by more waiting.
const kiwixTimeout = 20 * time.Second
// queryKiwix — the offline encyclopedia. The last local source: everything of
// his has already had its turn and found nothing, so the question is a world
// question, and reading beats recalling for a 1.7B.
+29 -1
View File
@@ -751,7 +751,15 @@ func run(args []string) error {
if voiceW != nil {
voiceW.close()
}
wg.Wait()
// Bounded. Every worker below watches ctx, but one parked in a model call
// or an HTTP fetch can outlast the supervisor's patience, and run() has to
// return for `defer st.Close()` to seal the database. A worker abandoned
// mid-tick loses one tick; a shutdown that never returns loses every write
// since the last clean stop — which is how the deployed ciphertext went
// eleven days stale in July 2026.
if !waitWorkers(&wg, workerGrace) {
log.Printf("mavend: workers still running after %s, sealing anyway", workerGrace)
}
log.Printf("mavend: bye")
return nil
}
@@ -782,3 +790,23 @@ func contextBlockFn(cfg *config.Config, now func() time.Time) func() string {
f := personaFacts(cfg)
return func() string { return f.Block(now()) }
}
// workerGrace — how long shutdown waits for the background workers before it
// goes ahead and seals without them. Comfortably inside docker's ten-second
// default so the seal still lands before SIGKILL.
const workerGrace = 4 * time.Second
// waitWorkers waits on wg for at most d. Reports whether they all finished.
func waitWorkers(wg *sync.WaitGroup, d time.Duration) bool {
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
return true
case <-time.After(d):
return false
}
}