f1a809121b
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
307 lines
9.0 KiB
Go
307 lines
9.0 KiB
Go
// voice/server.go — the daemon-side TCP listener.
|
|
//
|
|
// Accept loop mirrors ipc/server.go shape: one Server, one goroutine per
|
|
// conn, recover per conn so a misbehaving client can't kill core. Two key
|
|
// differences from ipc:
|
|
//
|
|
// - Each conn registers a Session in *Sessions before it reads the first
|
|
// frame. The conn serves requests AND receives server-initiated Pushes
|
|
// through the same conn (the voicesink calls Sessions.PushToMostRecent,
|
|
// which finds the session by lastActive and writes a Push frame on
|
|
// its conn). The conn's write side is therefore shared: serveConn's
|
|
// Response writes vs. the voicesink's Push writes; both serialize via
|
|
// the per-Session mutex.
|
|
//
|
|
// - The handler is a voice.Handler, NOT a CoreAPI-style interface. The
|
|
// single method, HandlePushToTalk(ctx, req, sessionID) → resp, does
|
|
// the full reactive path (stt → router → action → replier → tts) and
|
|
// returns the reply. The daemon provides a concrete impl wired to its
|
|
// stt/tts/router/coreAPI; the voice package stays free of those imports
|
|
// (it's just the wire surface).
|
|
package voice
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Handler — the daemon-side reactive path. The voice package defines the
|
|
// interface; the daemon wires a concrete handler that knows stt/tts/router.
|
|
// This keeps voice from importing every other package in the system.
|
|
//
|
|
// The handler is called on a per-conn goroutine; it must be safe for
|
|
// concurrent use by multiple callers (the daemon's impl routes through
|
|
// the wired singleton stt.Transcriber / tts.Synthesizer / router, all of
|
|
// which are concurrency-safe).
|
|
type Handler interface {
|
|
HandlePushToTalk(ctx context.Context, req PushToTalkReq, sessionID uint64) (PushToTalkResp, error)
|
|
}
|
|
|
|
// Server — maven's client↔core network surface. Listens on a TCP address
|
|
// (inside the wg tunnel; bind to wg-egress only — the listener doesn't
|
|
// enforce that, the daemon's config picks the bind addr). Each conn is a
|
|
// session. Server holds *Sessions so the voicesink can ask
|
|
// PushToMostRecent.
|
|
type Server struct {
|
|
addr string
|
|
handler Handler
|
|
sessions *Sessions
|
|
|
|
ln net.Listener
|
|
wg sync.WaitGroup
|
|
done chan struct{}
|
|
|
|
// Accepted conns, tracked so Close can shut them. Same defect as
|
|
// ipc/server.go had: closing only the listener leaves every idle client
|
|
// parked in readFrame, wg.Wait never returns, and the daemon dies to
|
|
// SIGKILL without sealing the database.
|
|
connMu sync.Mutex
|
|
conns map[net.Conn]struct{}
|
|
}
|
|
|
|
// closeGrace — how long Close waits for in-flight dispatches before dropping
|
|
// them. A push-to-talk turn can be mid-inference; abandoning one costs a reply,
|
|
// hanging costs every write since the last clean shutdown.
|
|
const closeGrace = 3 * time.Second
|
|
|
|
// NewServer builds a Server bound to addr (e.g. "127.0.0.1:9100" for a
|
|
// local-only smoke; production: a wg-tunnel address). handler is the
|
|
// reactive handler; sessions is shared with the voicesink (the daemon
|
|
// constructs one, passes to both Server and voicesink).
|
|
func NewServer(addr string, handler Handler, sessions *Sessions) *Server {
|
|
return &Server{
|
|
addr: addr,
|
|
handler: handler,
|
|
sessions: sessions,
|
|
done: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Listen binds the TCP listener. Today's floor is plaintext — the auth
|
|
// cascade (mTLS, passkey) layers in at the same addr without changing the
|
|
// wire shape. Production picks a bind addr that's inside the wg tunnel
|
|
// (the wg layer IS the L0 floor); the listener doesn't enforce that, the
|
|
// config does.
|
|
func (s *Server) Listen() error {
|
|
ln, err := net.Listen("tcp", s.addr)
|
|
if err != nil {
|
|
return fmt.Errorf("voice: listen %s: %w", s.addr, err)
|
|
}
|
|
s.ln = ln
|
|
return nil
|
|
}
|
|
|
|
// Addr returns the bound TCP address (after Listen).
|
|
func (s *Server) Addr() string {
|
|
if s.ln == nil {
|
|
return s.addr
|
|
}
|
|
return s.ln.Addr().String()
|
|
}
|
|
|
|
// Serve accepts connections until the listener closes. Per-conn goroutine;
|
|
// per-conn recover so a misbehaving client can't crash core.
|
|
func (s *Server) Serve() error {
|
|
if s.ln == nil {
|
|
return fmt.Errorf("voice: serve before listen")
|
|
}
|
|
for {
|
|
c, err := s.ln.Accept()
|
|
if err != nil {
|
|
select {
|
|
case <-s.done:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("voice: accept: %w", err)
|
|
}
|
|
}
|
|
s.wg.Add(1)
|
|
s.trackConn(c)
|
|
go func(c net.Conn) {
|
|
defer s.wg.Done()
|
|
defer s.untrackConn(c)
|
|
s.serveConn(c)
|
|
}(c)
|
|
}
|
|
}
|
|
|
|
// serveConn — one client's lifecycle. Registers a Session, reads requests
|
|
// in a loop, dispatches to Handler, writes Responses, removes the session
|
|
// on EOF / read error / ctx cancel.
|
|
func (s *Server) serveConn(c net.Conn) {
|
|
defer c.Close()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
// TODO(step-up): the auth handshake populates the surface from mTLS /
|
|
// passkey enrollment. Today the floor sets SurfacePCClient (the
|
|
// reference client's surface, capped at L3 per auth.MaxLayer). The
|
|
// reference client doesn't carry passkey yet, so the floor is "you
|
|
// got through the wg tunnel ⇒ you're on SurfacePCClient by name; the
|
|
// passkey step-up will cap-before-L3 untrusted pop sessions later."
|
|
sess := s.sessions.Add(c, SurfacePCClient)
|
|
defer s.sessions.Remove(sess.ID)
|
|
log.Printf("voice: client %d connected from %s", sess.ID, sess.RemoteAddr)
|
|
|
|
for {
|
|
var req Request
|
|
if err := readFrame(c, &req); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
// quiet disconnect; common during shutdown.
|
|
} else {
|
|
log.Printf("voice: client %d read: %v", sess.ID, err)
|
|
}
|
|
return
|
|
}
|
|
s.sessions.Touch(sess.ID, time.Now())
|
|
|
|
result, err := s.safeDispatch(ctx, c.RemoteAddr(), sess.ID, req)
|
|
resp := Response{ID: req.ID}
|
|
if err != nil {
|
|
resp.Error = rpcErr(err)
|
|
} else {
|
|
resp.Result = result
|
|
}
|
|
if err := writeFrame(c, &resp); err != nil {
|
|
log.Printf("voice: client %d write: %v", sess.ID, err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) safeDispatch(ctx context.Context, addr net.Addr, sid uint64, req Request) (result json.RawMessage, err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = fmt.Errorf("voice: panic dispatching %s (sid %d): %v", req.Method, sid, r)
|
|
}
|
|
}()
|
|
return s.dispatch(ctx, sid, req)
|
|
}
|
|
|
|
func (s *Server) dispatch(ctx context.Context, sid uint64, req Request) (json.RawMessage, error) {
|
|
switch req.Method {
|
|
case MethodPushToTalk:
|
|
var p PushToTalkReq
|
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
// Floor default: surface = SurfacePCClient (set here so a floor
|
|
// client that didn't populate the wire field still gets the
|
|
// reference surface). Production: handshake sets it; req.Surface
|
|
// wins over the default.
|
|
if p.Surface == "" {
|
|
p.Surface = SurfacePCClient
|
|
}
|
|
resp, err := s.handler.HandlePushToTalk(ctx, p, sid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalResult(resp), nil
|
|
|
|
case MethodPong:
|
|
// Pong updates lastActive (the Touch above already did it on the
|
|
// read); no further action. Returns an empty success.
|
|
return marshalResult(nil), nil
|
|
|
|
default:
|
|
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
|
}
|
|
}
|
|
|
|
// Close stops accepting and waits for in-flight conns to drain.
|
|
func (s *Server) Close() error {
|
|
select {
|
|
case <-s.done:
|
|
return nil
|
|
default:
|
|
close(s.done)
|
|
}
|
|
var err error
|
|
if s.ln != nil {
|
|
err = s.ln.Close()
|
|
}
|
|
// Close the accepted conns too, or a client that is merely idle keeps
|
|
// serveConn blocked in readFrame forever.
|
|
s.closeConns()
|
|
if !waitTimeout(&s.wg, closeGrace) {
|
|
log.Printf("voice: %d connection(s) still busy after %s, closing anyway", s.liveConns(), closeGrace)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Server) trackConn(c net.Conn) {
|
|
s.connMu.Lock()
|
|
defer s.connMu.Unlock()
|
|
if s.conns == nil {
|
|
s.conns = make(map[net.Conn]struct{})
|
|
}
|
|
s.conns[c] = struct{}{}
|
|
}
|
|
|
|
func (s *Server) untrackConn(c net.Conn) {
|
|
s.connMu.Lock()
|
|
defer s.connMu.Unlock()
|
|
delete(s.conns, c)
|
|
}
|
|
|
|
func (s *Server) liveConns() int {
|
|
s.connMu.Lock()
|
|
defer s.connMu.Unlock()
|
|
return len(s.conns)
|
|
}
|
|
|
|
// closeConns unblocks every parked reader. serveConn's own defer closes the
|
|
// conn again; a second Close on a net.Conn is a harmless error.
|
|
func (s *Server) closeConns() {
|
|
s.connMu.Lock()
|
|
conns := make([]net.Conn, 0, len(s.conns))
|
|
for c := range s.conns {
|
|
conns = append(conns, c)
|
|
}
|
|
s.connMu.Unlock()
|
|
for _, c := range conns {
|
|
_ = c.Close()
|
|
}
|
|
}
|
|
|
|
// waitTimeout waits on wg, but not forever. Reports whether it finished.
|
|
func waitTimeout(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
|
|
}
|
|
}
|
|
|
|
func unmarshalParams(raw json.RawMessage, v any) error {
|
|
if len(raw) == 0 {
|
|
raw = []byte("null")
|
|
}
|
|
if err := json.Unmarshal(raw, v); err != nil {
|
|
return fmt.Errorf("%w: %v", ErrBadParams, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func marshalResult(v any) json.RawMessage {
|
|
if v == nil {
|
|
return json.RawMessage("null")
|
|
}
|
|
b, _ := json.Marshal(v)
|
|
return b
|
|
}
|
|
|
|
// io.EOF — used by serveConn to detect a quiet client disconnect.
|