@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.
@moq/qmux'sSessionadvertises itself as a drop-inWebTransportreplacement (implements WebTransport, README: "Use as a drop-inWebTransportreplacement"), but itsclosedpromise diverges from the WebTransport contract: it always resolves and never rejects, even when the session ends abnormally.Per the W3C WebTransport spec,
closedis "fulfilled when the associated WebTransport object is closed gracefully, or rejected when it is closed abruptly or failed on initialization" — abrupt termination rejects with aWebTransportError(source: "session"). Native browser WebTransport behaves this way.What qmux does instead
All refs against
main@ 524449b,js/qmux/src/session.ts:closedis constructed resolve-only — only the resolver is ever captured (session.ts#L452-L454); there is no reject counterpart anywhere in the class (contrastready, which captures both). Every termination path funnels into the idempotent#close(code, reason)(L1360), which unconditionally resolvesclosedwith{closeCode, reason}(L1372-L1375).That includes every abnormal ending, which a spec-conformant
closedwould reject:closeCode: 1006closedrejected (error)closeCode: 10061006closeCode: 1006closeCode: 1002closeCode: 1003closeCode: 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:
On qmux the
catcharm 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'sclosed()yields anErrorwhose variant separates graceful (Error::Closed,ConnectionClosed{code,reason}) from abnormal (IdleTimeout,ProtocolViolation, …). The JS port flattens that into an always-resolvedWebTransportCloseInfo.Suggested fix
Capture
closed.rejectalongside the resolver and have#close()(or its callers) distinguish the two classes:{closeCode, reason}: localclose(), peer CONNECTION_CLOSE.WebTransportError-shaped error (source: "session"): WebSocket open failure/error/unexpected close, read errors, protocol violations, idle timeout. (Where the globalWebTransportErrorconstructor is unavailable, anErrorsubclass withsource = "session"matching the interface would keep the polyfill self-contained.)The existing tests in
js/qmux/src/session.test.tscurrently assert the resolve-always behavior (e.g. protocol-violation casesawait session.closedand checkcloseCode1002/1003), so they'd flip to.rejectsassertions for the abnormal cases.Happy to submit a PR for this if the approach sounds right.