Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions quest/m1/voice-goodbye-drain.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@
## Goal

When a `/voice` call ends, the page's final `session-ending` record reaches
the agent because the connection drains on close, not because the page waits
a fixed 250 ms (`GOODBYE_MS` in `src/components/voice-demo.tsx`) before
unmounting `<moq-publish>`.
the agent because the connection drains on close, not because of a fixed
wait.

## Plan

Decided 2026-10-19: once `@moq/net`'s draining `close()` is available, drop
the wait and close through it. The `moq` submodule now provides it.
The page no longer waits before unmounting `<moq-publish>`. Unmounting only
releases the element's pooled connection handle, and the shared session keeps
sending the cleanly finished transcript track until the pool's 2 s linger
(`LINGER_MS` in `moq/js/net/src/connection/pool.ts`) expires. A local relay
test with the real element delivered the record every time; closing the
connection at once lost it every time.

Check how `<moq-publish>` closes its connection before relying on it: the
element holds a pooled connection handle, and the pool lingers before
closing, so unmounting may not drain the broadcast's last frames the way an
explicit `close()` does. Prove it with a real call: hang up and confirm the
agent logs the `session-ending` record rather than a timeout.
Remaining:

- Upstream in moq-dev/moq: when the linger expires, `Reload`'s teardown
(`moq/js/net/src/connection/reload.ts`) calls `Established.abort()`, so the
draining `close()` never runs on this path and the linger is still a fixed
wait. Swapping it for `close()` delivered the record with the connection
closed at once. Bump the `moq` submodule once that lands.
- A real call on new.moq.dev/voice: hang up and confirm the agent logs the
`session-ending` record rather than a timeout. Needs a maintainer (Turnstile
and a mic).
14 changes: 5 additions & 9 deletions src/components/voice-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,6 @@ const POLL_MS = 2_000;
// lands a little before our pickup.
const LIMIT_SLACK_SECS = 15;

// Closing the connection drops whatever has not reached the wire, so the goodbye gets a moment
// first, the same as @pipecat-ai/moq-transport.
const GOODBYE_MS = 250;

// The API's refusal codes plus the ones only the page can see. Copy comes from here, never from
// the API's error string.
type Outcome =
Expand Down Expand Up @@ -193,7 +189,7 @@ function clock(secs: number): string {
export default function VoiceDemo() {
const [phase, setPhase] = createSignal<Phase>("idle");
const [outcome, setOutcome] = createSignal<Outcome>();
// Mounts the publisher and player; it outlives `current` by the goodbye.
// Mounts the publisher and player.
const [mounted, setMounted] = createSignal<Call>();
const [remaining, setRemaining] = createSignal<number>();
const [transcript, setTranscript] = createSignal<Turn[]>([]);
Expand Down Expand Up @@ -236,11 +232,11 @@ export default function VoiceDemo() {
if (call) {
for (const timer of call.timers) clearTimeout(timer);
clearInterval(call.ticker);
// The agent hangs up at once on this, instead of waiting for us to redial.
// The agent hangs up at once on this, instead of waiting for us to redial. Unmounting
// right after is safe: <moq-publish> only releases its pooled connection, which keeps
// sending the cleanly finished track until the pool's linger closes it.
send(call, { label: "moq-transport", type: "session-ending" });
const unmount = () => setMounted((m) => (m === call ? undefined : m));
if (call.producer) setTimeout(unmount, GOODBYE_MS);
else unmount();
setMounted(undefined);
}
setRemaining(undefined);
setOutcome(why);
Expand Down
Loading