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