Files
Maven/internal/voice/server.go
T
2026-07-03 00:32:48 +02:00

235 lines
7.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{}
}
// 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.