idleclose

package
v0.15.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 29, 2026 License: GPL-3.0 Imports: 7 Imported by: 0

Documentation

Overview

Package idleclose is the Voice Instance's Idle Close watchdog (CONTEXT.md "Idle Close", ADR-0061): it ends a Voice Session that has processed no audio for the Idle Close Window, and sheds the quietest session when the process crosses a configured resource ceiling.

It exists because a Voice Session holds real resources for as long as it runs — a Discord voice connection with its DAVE/MLS state, a claim-plane heartbeat, a per-session tape ring and its consent poller, a VAD session per Speaker Lane — and none of that is bounded by anything a silent room does. A production session left running for a day in a channel nobody ever joined paid all of it and produced nothing.

The shape deliberately mirrors the ADR-0046 hard spend cap, the other policy that ends a live Voice Session: the watchdog only DECIDES, and hands the decision to a per-session callback the owner supplies (the session Manager sets its end_reason override and cancels the run ctx). Only the Voice Instance hosting a session may end it — no sweeper reaches across the fleet (ADR-0006, ADR-0057 (e)) — and the row closes 'ended', never 'failed': a deliberate policy stop is not a fault (ADR-0043, ADR-0046).

The hot path is one atomic increment. Session.Mark is called once per INBOUND room-audio frame the voice loop processes, so it takes no lock, allocates nothing, and reads no clock; the watchdog samples the counter on its own sweep and only then consults the clock. That is also why idleness is measured off a COUNTER rather than a timestamp: the silence clock (pkg/voice/wire) keeps the VAD and Segmenter busy at ~31 Hz through a completely empty channel, so anything keyed off those call rates would never go stale.

The outbound half — the Bot speaking with nobody talking to it (a GM /say, a voiced recap, a Highlight replay) — marks ONCE PER REQUEST at its seam in internal/session, not once per Opus frame. That is deliberate: it costs one increment instead of one per 20 ms of speech, and it restarts the window from when the GM asked rather than from the last packet of the resulting audio.

Index

Constants

View Source
const (
	// ReasonIdle is written when a Voice Session processed no audio for the whole
	// Idle Close Window.
	ReasonIdle = "idle_no_audio: the Voice Session processed no audio for the idle close window"
	// ReasonResource is written when the Voice Instance crossed a configured
	// resource ceiling and this session was the quietest one it hosted.
	ReasonResource = "resource_ceiling: the Voice Instance crossed its configured resource ceiling"
	// ReasonChurn is written when a Voice Session ran through more Discord
	// reconnect cycles than its ceiling allows.
	ReasonChurn = "reconnect_churn: the Voice Session exceeded its reconnect-cycle ceiling"
	// ReasonMediaDead is written when a Voice Session processed no audio for the
	// whole Idle Close Window AND the media watchdog had flagged the inbound
	// media path as dead (#633: remote participants kept announcing speech while
	// no RTP arrived, and the rebuilds it triggered never brought audio back).
	// The same breach as [ReasonIdle], told apart so a transport fault stops
	// masquerading as a quiet table.
	ReasonMediaDead = "media_path_dead: the Voice Session received no audio while participants kept speaking"
)

The end_reason values an Idle Close writes. Each is an ADR-0043 pair: a stable, greppable machine prefix plus prose, exactly like ADR-0046's `spend_cap_hard`. The prose is fixed rather than interpolated so the prefix stays the whole contract and a log/UI grep never has to tolerate a varying tail.

Variables

This section is empty.

Functions

This section is empty.

Types

type Guard

type Guard struct {
	// contains filtered or unexported fields
}

Guard is one Voice Instance's watchdog over every Voice Session it hosts. Build it with New, run its single goroutine with Guard.Run, and enroll each session with Guard.Enroll. A nil *Guard is a valid feature-off value: every method is a no-op on it.

func New

func New(policy Policy, log *slog.Logger) *Guard

New builds a Guard for policy. A nil logger discards logs. It starts nothing — call Guard.Run.

func (*Guard) Enroll

func (g *Guard) Enroll(id string, close func(reason string)) *Session

Enroll registers a Voice Session with the watchdog and returns its handle, or nil when the Guard is nil or its Policy arms no check — so a caller can skip wiring the activity tap entirely on the feature-off path. id names the session in the watchdog's logs only.

close is invoked at most once, from the sweep goroutine, with one of ReasonIdle / ReasonMediaDead / ReasonChurn / ReasonResource. The session stays enrolled after it fires (its loop takes seconds to unwind through its finalizers) but is never chosen again.

The returned handle is INERT until Session.Activate: it accepts marks and counts cycles, but no sweep will close it. Enroll therefore cannot fire a breach against a session the owner has not finished starting — an unwinnable race, since the owner needs the handle to wire the activity mark before it can commit the session.

func (*Guard) Enrolled

func (g *Guard) Enrolled() int

Enrolled reports how many Voice Sessions this watchdog currently tracks. A nil Guard tracks none. It is the seam that makes "the owner released its enrollment" observable — a handle that outlives its session is a leak of exactly the kind this package exists to prevent.

func (*Guard) Run

func (g *Guard) Run(ctx context.Context)

Run sweeps every enrolled Voice Session on the Policy's cadence until ctx is done. It returns immediately when the Guard is nil or its Policy arms no check, so a feature-off deployment runs no ticker at all.

type Policy

type Policy struct {
	// Window is the Idle Close Window: how long a Voice Session may process no
	// audio before the hosting Voice Instance closes it. Zero or negative disables
	// idle closing.
	Window time.Duration
	// Sweep is the watchdog cadence. Zero or negative takes [defaultSweep], capped
	// so it always fits inside a short Window (see [Guard.sweepInterval]).
	Sweep time.Duration
	// HeapCeiling is the Go heap footprint in bytes past which the Voice Instance
	// sheds its quietest Voice Session. Zero disables the check.
	//
	// It is deliberately a PROCESS reading, not a per-session one: Go attributes no
	// heap to a logical owner, and inventing a per-session number would be a
	// fiction. So the ceiling answers "this Voice Instance is over its budget",
	// and the victim rule (quietest first) makes the shed the least damaging one
	// available.
	HeapCeiling uint64
	// GoroutineCeiling is the process goroutine count past which the Voice Instance
	// sheds its quietest Voice Session. Zero disables the check. Same process-wide
	// caveat as HeapCeiling.
	GoroutineCeiling int
	// MaxCycles is how many Discord connect cycles one Voice Session may run
	// through before the hosting Voice Instance closes it. Zero disables the check.
	//
	// Unlike the two ceilings above this one IS per-session and exact, which is why
	// it is on by default: every reconnect cycle rebuilds the whole per-cycle world
	// — the Discord voice connection, the codec, the provider adapters each with
	// their own http.Transport, a Silero session per Speaker Lane, and (with
	// streaming STT) a fresh realtime websocket — and some of that provably does not
	// come back. A session that has cycled hundreds of times has leaked in
	// proportion, whatever else it is doing, so the count is the closest thing to a
	// direct per-session leak measurement the process actually has.
	MaxCycles int
}

Policy is the deployment's Idle Close configuration, parsed from the GLYPHOXA_VOICE_IDLE_* env vars in the composition root (cmd/glyphoxa/boot.go) so the knobs stay deployment-owned and never leak into internal/session.

Every check is independently opt-out: the zero Policy disables the feature completely, and Guard.Enroll then returns nil so the voice loop wires no activity tap and behaves byte-identically to the pre-Idle-Close path.

func (Policy) Enabled

func (p Policy) Enabled() bool

Enabled reports whether the Policy arms any check at all. The composition root branches on it to decide whether to run a watchdog goroutine at all.

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session is one enrolled Voice Session's handle. The voice loop calls Session.Mark on the audio path and the owner calls Session.Release when the session's loop returns. A nil *Session is the feature-off value: both methods are no-ops on it.

func (*Session) Activate

func (s *Session) Activate()

Activate admits the session to the watchdog's sweep. The owner calls it once its session is genuinely live — for the session Manager, the moment the session is committed to m.active, so the close callback it handed to Guard.Enroll can actually find something to end. Before it, the handle collects marks and cycles but is never swept. Idempotent and safe on a nil handle.

func (*Session) CycleStarted

func (s *Session) CycleStarted()

CycleStarted records that this Voice Session began another Discord connect cycle. The reconnect loop calls it once per attempt, including the first, so the count is "cycles run" and a ceiling of N permits N-1 reconnects. Safe on a nil handle.

func (*Session) Mark

func (s *Session) Mark()

Mark records that this Voice Session processed audio. It is THE hot path — called once per inbound room-audio frame, roughly 50/s per active speaker — so it is a single atomic increment: no lock, no allocation, and no clock read. Safe on a nil handle.

Bot speech marks here too, but once per REQUEST (a /say, a voiced recap, a Highlight replay) rather than once per outbound frame — see the package doc.

func (*Session) MediaSuspect added in v0.15.0

func (s *Session) MediaSuspect()

MediaSuspect records that the media watchdog (#633) declared this session's inbound media path dead: remote participants kept announcing speech while no RTP arrived. If the flag is still standing when the Idle Close Window breaches, the close reports ReasonMediaDead instead of ReasonIdle; audio flowing again clears it on the next sweep. One atomic store, safe on a nil handle.

func (*Session) Release

func (s *Session) Release()

Release deregisters the session so no later sweep can consider it. The owner calls it once its loop has returned; it is idempotent and safe on a nil handle.

type Usage

type Usage struct {
	// HeapBytes is the Go runtime's memory footprint: everything it has mapped and
	// not returned to the OS. It tracks a container memory limit far better than
	// live-object bytes, which is what an operator actually sets a ceiling against.
	HeapBytes uint64
	// Goroutines is runtime.NumGoroutine().
	Goroutines int
}

Usage is one sample of the Voice Instance's resource footprint, as the ceilings are judged against.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL