// 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{} } // 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) go func(c net.Conn) { defer s.wg.Done() 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() // 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(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(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(context.Background(), 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() } s.wg.Wait() return err } 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.