Skip to content

Drop the voice page's goodbye wait - #201

Open
kixelated wants to merge 2 commits into
mainfrom
quest/m1/voice-goodbye-drain
Open

kixelated wants to merge 2 commits into
mainfrom
quest/m1/voice-goodbye-drain

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Drops the voice page's 250 ms GOODBYE_MS wait before unmounting <moq-publish> on hang-up. Quest: quest/m1/voice-goodbye-drain.md. The quest stays open: the drain itself is an upstream change, and the real-call check needs a maintainer.

Root cause

The wait assumed that unmounting closes the connection and drops anything not yet sent. <moq-publish> doesn't do that:

  1. disconnectedCallback only clears #enabled (moq/js/publish/src/element.ts). The broadcast's tracks close cleanly, and the page's bindPublish cleanup calls producer.finish(). The lite publisher keeps writing group streams that are already open after a clean track close.
  2. The Moq.Connection handle releases its pool lease. The shared session lingers for LINGER_MS (2 s, moq/js/net/src/connection/pool.ts) and keeps delivering during that time.
  3. When the linger expires, Reload's run cleanup calls Established.abort(), not the draining close() (moq/js/net/src/connection/reload.ts). So the draining close() from moq main never runs on this path. The page has no Established to call it on, and the element has no API for it.

Unmounting right away is safe today because of the 2 s linger, which outlasts the old 250 ms wait. The linger is still a fixed wait, though, just upstream. To make the goodbye actually drain, the fix belongs in moq-dev/moq (see the recommendation below).

Proof

Run against a local moq-relay built from the submodule. Each run creates a separate subscriber session that reads transcript.json.z through the relay, the way the agent does. The publisher writes client-ready, waits until the subscriber has it, writes session-ending, and then unmounts right away.

Publisher Transport Teardown Result
Real <moq-publish> in the browser (el.remove()) WebTransport pool linger (as shipped) 5/5 arrived, 3 to 15 ms
Real <moq-publish> in the browser WebTransport connection closed at once (resetShared()) 0/5, subscriber gets remote error: 54
Same, with Reload patched to void current.close() WebTransport connection closed at once 5/5 arrived
Bun script: pooled Connection plus @moq/publish Broadcast WebSocket pool linger 5/5 arrived, plus once with 2 MB of filler queued ahead
Bun script WebSocket linger 0 0/5 (also lost with 200 KB and 2 MB filler)
Bun script, Reload patched WebSocket linger 0 3/3 arrived (0, 200 KB, 2 MB filler)

The Reload patch was local only and is not part of this PR.

Bun proof script
// Does the voice page's final `session-ending` record reach a remote subscriber when the
// publisher is torn down right after writing it, with no wait?
//
// The publisher side mirrors <moq-publish>: a pooled Moq.Connection and @moq/publish's Broadcast,
// both gated on one `enabled` signal that disconnectedCallback clears, plus the page's
// bindPublish effect writing the RTVI log. The subscriber is a separate, private session through
// the relay, like the agent.
//
//   bun goodbye.ts <linger-ms>   (2000 is the pool default; 0 closes the connection at once)
//
// Env: ROOT is the moq.dev worktree, RELAY the relay URL, PAD bytes of incompressible filler
// queued ahead of the goodbye so it is not already on the wire when teardown starts.

const ROOT = process.env.ROOT ?? "/home/kixelated/work/moq.dev-voice-goodbye-drain";
const Moq = await import(`${ROOT}/moq/js/net/src/index.ts`);
const Json = await import(`${ROOT}/moq/js/json/src/index.ts`);
const { Broadcast } = await import(`${ROOT}/moq/js/publish/src/broadcast.ts`);
const { Effect, Signal } = await import(`${ROOT}/moq/js/signals/src/index.ts`);
const { Milli } = await import(`${ROOT}/moq/js/clock/src/index.ts`);

const url = new URL(process.env.RELAY ?? "http://localhost:4443/");
const linger = Milli(Number(process.argv[2] ?? "2000"));
const pad = Number(process.env.PAD ?? "0");
const RTVI_TRACK = "transcript.json.z";
const name = Moq.Path.from(`goodbye-${crypto.randomUUID()}.hang`);

type Message = { type: string; seq: number; pad?: string };

// The agent: its own session, subscribed to our transcript track through the relay.
const agent = new Moq.Connection({ url, share: false });
while (!agent.origin.peek()) await new Promise((r) => setTimeout(r, 10));
const request = agent.origin.peek()!.request(name, { announced: true });

// The page: the element's wiring.
const enabled = new Signal(true);
const connection = new Moq.Connection({ url, enabled, linger });
const broadcast = new Broadcast({ origin: connection.origin, enabled, announce: true, name });
const page = new Effect();
let producer: InstanceType<typeof Json.Stream.Producer<Message>> | undefined;
page.run((effect) => {
	const net = effect.get(broadcast.net);
	if (!net) return;
	const p = new Json.Stream.Producer<Message>({ track: net.createTrack(RTVI_TRACK), compression: "deflate" });
	producer = p;
	effect.cleanup(() => {
		if (producer === p) producer = undefined;
		p.finish();
	});
});

const deadline = (ms: number, what: string) =>
	new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`timed out: ${what}`)), ms));

// Wait until the agent sees the broadcast, then read the log.
const active = await Promise.race([
	new Promise<NonNullable<ReturnType<typeof request.active.peek>>>((resolve) => {
		const check = () => {
			const a = request.active.peek();
			if (a) resolve(a);
		};
		request.active.subscribe(check);
		check();
	}),
	deadline(10_000, "announce"),
]);
const track = active.track(RTVI_TRACK).subscribe();
const consumer = new Json.Stream.Consumer<Message>({ track, compression: "deflate" });

producer?.append({ value: { type: "client-ready", seq: 0 } });
const first = await Promise.race([consumer.next(), deadline(10_000, "first record")]);
if (first?.value.type !== "client-ready") throw new Error(`unexpected first record ${JSON.stringify(first)}`);

// Filler the goodbye queues behind, then the goodbye, then unmount at once: what finish() does
// without GOODBYE_MS.
if (pad > 0) {
	const bytes = crypto.getRandomValues(new Uint8Array(pad / 2));
	producer?.append({ value: { type: "filler", seq: 1, pad: Buffer.from(bytes).toString("hex") } });
}
const hungUp = performance.now();
producer?.append({ value: { type: "session-ending", seq: 2 } });
enabled.set(false); // disconnectedCallback
page.close(); // the page's onCleanup for the mounted call

let last: string | undefined;
let error: unknown;
try {
	for (;;) {
		const next = await Promise.race([consumer.next(), deadline(10_000, "track end")]);
		if (!next) break;
		last = next.value.type;
		if (last === "session-ending") break;
	}
} catch (err) {
	error = err;
}
const ms = Math.round(performance.now() - hungUp);

broadcast.close();
connection.close();
agent.close();

if (last === "session-ending") {
	console.log(`linger=${linger}ms pad=${pad}: session-ending arrived ${ms}ms after teardown`);
	process.exit(0);
}
console.log(`linger=${linger}ms pad=${pad}: LOST session-ending (last=${last}, ${ms}ms, ${error ?? "clean end"})`);
process.exit(1);

Run it with RELAY=http://localhost:4493/ bun goodbye.ts 2000 (or 0 for no linger), against moq/demo/relay/localhost.toml with the port changed and [stats] removed.

Recommendation (upstream, moq-dev/moq)

In Reload, close with the draining Established.close() instead of abort() when the loop shuts down, so that the pool's linger expiry and Connection.close() on a private loop both drain. close() is already bounded to about 1 s. The one-line swap fixed every failing case above. Keep abort() for auth rejections and for redirects that are refused. Once that lands and the submodule is bumped, the comment in finish() should say the connection drains instead of mentioning the linger, and the quest can close after a real call.

Remaining proof

A real call on new.moq.dev/voice needs a maintainer, because it requires Turnstile and a mic, and the preview browser blocks mic access. Hang up and check that the agent logs the session-ending record (pipecat's _peer_goodbye path) instead of waiting out connection_timeout.

Checks

  • quest check, just fix, just check: pass
  • /voice in the dev server renders and returns to idle (the mic is blocked in the preview)

Follow-ups

  • Upstream: drain in Reload on close (above).
  • Bump the moq submodule once it lands, then reword the finish() comment.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 2 commits October 23, 2026 23:17
<moq-publish> never closes its connection on unmount: it releases a pooled
handle, and the shared session keeps sending the cleanly finished
transcript track until the pool's 2 s linger expires. The page's 250 ms
GOODBYE_MS was redundant. Keep the quest open for the upstream drain on
linger expiry and a real-call check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@kixelated
kixelated marked this pull request as ready for review October 24, 2026 06:39
@kixelated

Copy link
Copy Markdown
Collaborator Author

Outcome: the page now unmounts <moq-publish> right after writing session-ending; local relay tests show the record arrives through the pool's 2 s linger. Open decision for the maintainer: land this now (recommended, since the linger already outlasts the old 250 ms wait), or hold it until moq-dev/moq drains in Reload on close. The quest stays open for that upstream change and a real call on new.moq.dev/voice.

(Written by Claude Opus 5.5)

@coderabbitai

coderabbitai Bot commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 44 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b9369993-5f30-4ff8-840d-28b248452825
Commits

Reviewing files that changed from the base of the PR and between 76b0278 and 59bef3b.

Files selected for processing: 2
  • quest/m1/voice-goodbye-drain.md
  • src/components/voice-demo.tsx
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-24T06:40:20.981161Z 59bef3b Draft marked ready
🔒 Security Review ✅ Completed 2026-10-24T06:41:50.802535Z 59bef3b Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant