359ae81d1f
piper_handler: close stdin/stdout pipes on Start() failure and on WriteString error instead of leaking fds. Propagate WriteString error. worker/client: log SetDeadline errors instead of discarding them. voice/session: pushAudio marshals params inline and returns the marshal error instead of swallowing it via mustParams (removed). tool/matcher: log ListTools errors instead of silently returning an empty allowlist that refuses every act. config: applyDefaults now sets RouterThreshold and ToolTimeout defaults so consumers self-contained defaults are belt-and-suspenders.
174 lines
5.3 KiB
Go
174 lines
5.3 KiB
Go
// voice/session.go — the live-client registry.
|
|
//
|
|
// One session per connected client. The registry is the seam the voicesink
|
|
// asks ("play this on the most-recently-active client") and the seam the
|
|
// server mutates ("client X is active at T"). The voicesink doesn't know
|
|
// the conn — this package hides it; voicesink holds *Sessions, calls
|
|
// PushToMostRecent, gets nil-id back when no one's around, and surfaces
|
|
// that as "no live voice channel" (delivery falls back to away channels if
|
|
// the routing table insists, drops if it doesn't).
|
|
//
|
|
// Concurrency: one mutex around the map; per-session last-active updates
|
|
// go through the same lock as add/remove. Adds are rare (one per client
|
|
// connect); updates are once per request (inbound PushToTalk refreshes
|
|
// last-active). The lock is held briefly — no audio bytes flow through
|
|
// sessions; the sink ships audio through the per-conn writeFrame call,
|
|
// which is short.
|
|
package voice
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Session — one connected client. The server writes to it via pushAudio;
|
|
// the registry reads lastActive via LastActive; the conn is closed when
|
|
// the client disconnects (serveConn returns) or the server shuts down.
|
|
type Session struct {
|
|
ID uint64
|
|
Surface Surface
|
|
RemoteAddr string
|
|
|
|
// lastActive — last time we heard from this client (request frame OR
|
|
// Pong). PickRecent selects the max-lastActive session for routing.
|
|
lastActive atomic.Int64 // unix nano
|
|
|
|
mu sync.Mutex
|
|
conn net.Conn
|
|
closed bool
|
|
}
|
|
|
|
func (s *Session) setLastActive(t time.Time) {
|
|
s.lastActive.Store(t.UnixNano())
|
|
}
|
|
|
|
// LastActive — int64 unixnano, atomic read. PickRecent compares these.
|
|
func (s *Session) LastActive() int64 { return s.lastActive.Load() }
|
|
|
|
// IsClosed reports whether the conn has been torn down. PickRecent skips
|
|
// closed sessions explicitly; an in-flight close race is fine — pushAudio
|
|
// returns an error and the sink reroutes (the same path as "no session").
|
|
func (s *Session) IsClosed() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.closed
|
|
}
|
|
|
|
// pushAudio writes one Push frame on this session's conn. Acquires the
|
|
// session lock so a concurrent close doesn't race with a write (the
|
|
// voicesink + the proactive-nudge pusher are goroutines separate from
|
|
// serveConn). Returns an error if the conn is closed.
|
|
func (s *Session) pushAudio(p AudioNudgePush) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed || s.conn == nil {
|
|
return fmt.Errorf("voice: session %d closed: %w", s.ID, ErrNoSession)
|
|
}
|
|
params, err := json.Marshal(p)
|
|
if err != nil {
|
|
return fmt.Errorf("voice: marshal push params: %w", err)
|
|
}
|
|
return writeFrame(s.conn, Push{Kind: PushKindAudioNudge, Params: params})
|
|
}
|
|
|
|
func (s *Session) shutdown() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return
|
|
}
|
|
s.closed = true
|
|
if s.conn != nil {
|
|
_ = s.conn.Close()
|
|
}
|
|
}
|
|
|
|
// Sessions — the registry of live clients. Held by the Server AND by
|
|
// voicesink (same pointer; the daemon passes one to both). Mutex around the
|
|
// map; per-session conn writes use the session's own lock.
|
|
type Sessions struct {
|
|
mu sync.Mutex
|
|
sess map[uint64]*Session
|
|
nextID uint64
|
|
}
|
|
|
|
func NewSessions() *Sessions {
|
|
return &Sessions{sess: make(map[uint64]*Session)}
|
|
}
|
|
|
|
// Add registers a new client conn. Returns the Session (caller uses it to
|
|
// push; serveConn reads the next request from conn). Surface defaults to
|
|
// SurfacePCClient today (the reference client's surface; mTLS / passkey
|
|
// enrollment will populate this from the auth handshake instead). Removes
|
|
// the session on serveConn's return via Remove.
|
|
func (r *Sessions) Add(c net.Conn, surface Surface) *Session {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.nextID++
|
|
s := &Session{
|
|
ID: r.nextID,
|
|
Surface: surface,
|
|
RemoteAddr: c.RemoteAddr().String(),
|
|
conn: c,
|
|
}
|
|
s.setLastActive(time.Now())
|
|
r.sess[s.ID] = s
|
|
return s
|
|
}
|
|
|
|
// Remove drops a session (serveConn returned — client closed or read err).
|
|
func (r *Sessions) Remove(id uint64) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if s, ok := r.sess[id]; ok {
|
|
s.shutdown()
|
|
delete(r.sess, id)
|
|
}
|
|
}
|
|
|
|
// Touch refreshes a session's lastActive to now (called on each request).
|
|
// No-op if the session is gone (a stale request after disconnect).
|
|
func (r *Sessions) Touch(id uint64, now time.Time) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if s, ok := r.sess[id]; ok {
|
|
s.setLastActive(now)
|
|
}
|
|
}
|
|
|
|
// Active returns the count of live sessions. For logging / readiness.
|
|
func (r *Sessions) Active() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return len(r.sess)
|
|
}
|
|
|
|
// PushToMostRecent — the voicesink's primary call. Delivers p to the
|
|
// session with the maximum lastActive; today the only Push kind. Returns
|
|
// ErrNoSession (wrapped) when no live session exists — the dispatcher /
|
|
// voicesink surfaces that as "voice channel not available, route away."
|
|
//
|
|
// We DO NOT broadcast; the spec's "most-recently-active client plays it"
|
|
// is enforced here. If the user has the phone and the laptop open, the
|
|
// one they used most recently plays; the other doesn't double-ring.
|
|
func (r *Sessions) PushToMostRecent(ctx context.Context, p AudioNudgePush) error {
|
|
r.mu.Lock()
|
|
var best *Session
|
|
for _, s := range r.sess {
|
|
if best == nil || s.LastActive() > best.LastActive() {
|
|
best = s
|
|
}
|
|
}
|
|
r.mu.Unlock()
|
|
if best == nil {
|
|
return fmt.Errorf("voice: push: %w", ErrNoSession)
|
|
}
|
|
return best.pushAudio(p)
|
|
}
|
|
|