Skip to content

js/qmux: session.closed never rejects — abnormal termination is indistinguishable from a graceful close #290

Description

@fperex

@moq/qmux's Session advertises itself as a drop-in WebTransport replacement (implements WebTransport, README: "Use as a drop-in WebTransport replacement"), but its closed promise diverges from the WebTransport contract: it always resolves and never rejects, even when the session ends abnormally.

Per the W3C WebTransport spec, closed is "fulfilled when the associated WebTransport object is closed gracefully, or rejected when it is closed abruptly or failed on initialization" — abrupt termination rejects with a WebTransportError (source: "session"). Native browser WebTransport behaves this way.

What qmux does instead

All refs against main @ 524449b, js/qmux/src/session.ts:

closed is constructed resolve-only — only the resolver is ever captured (session.ts#L452-L454); there is no reject counterpart anywhere in the class (contrast ready, which captures both). Every termination path funnels into the idempotent #close(code, reason) (L1360), which unconditionally resolves closed with {closeCode, reason} (L1372-L1375).

That includes every abnormal ending, which a spec-conformant closed would reject:

Path Site Resolves with
WebSocket failed to open L494 closeCode: 1006
WebSocket closed rejected (error) L504 closeCode: 1006
WebSocket dropped (onclose, no CONNECTION_CLOSE) L500 ws code / 1006
Read-loop error mid-session L524 closeCode: 1006
Protocol violations (decode failure, frame too large, stream limit, flow control) L586, L914, L940, L965, L1044 closeCode: 1002
Text frame received L517 closeCode: 1003
Idle timeout L749 closeCode: 0, "idle timeout"

Only local transport.close() (L1446) and a peer CONNECTION_CLOSE frame (L609) are genuinely graceful closes that should resolve.

Why it matters

The standard consumer idiom is:

try {
	await session.closed; // clean shutdown
} catch (e) {
	scheduleReconnect(e); // abnormal drop
}

On qmux the catch arm is unreachable, so any code written against the native contract silently treats every network drop / protocol error / idle timeout as a clean close. Concretely this broke reconnect in moq: Safari and Firefox use the qmux WebSocket fallback, and a relay drop left a permanently dead session with status "connected" and no retry — see moq-dev/moq#2183 (closed with "this sounds like a qmux bug… file an issue or submit a PR to qmux", hence this issue).

Notably the Rust side already makes this distinction: web-transport-trait's closed() yields an Error whose variant separates graceful (Error::Closed, ConnectionClosed{code,reason}) from abnormal (IdleTimeout, ProtocolViolation, …). The JS port flattens that into an always-resolved WebTransportCloseInfo.

Suggested fix

Capture closed.reject alongside the resolver and have #close() (or its callers) distinguish the two classes:

  • Resolve with {closeCode, reason}: local close(), peer CONNECTION_CLOSE.
  • Reject with a WebTransportError-shaped error (source: "session"): WebSocket open failure/error/unexpected close, read errors, protocol violations, idle timeout. (Where the global WebTransportError constructor is unavailable, an Error subclass with source = "session" matching the interface would keep the polyfill self-contained.)

The existing tests in js/qmux/src/session.test.ts currently assert the resolve-always behavior (e.g. protocol-violation cases await session.closed and check closeCode 1002/1003), so they'd flip to .rejects assertions for the abnormal cases.

Happy to submit a PR for this if the approach sounds right.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions