Files
Maven/internal/voice/session.go
T
kami d52f60c54e maven: fix test mocks for CalendarEvents interface (verification)
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-06 04:20:16 +04:00

173 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)
}