Skip to content

feat: initial implementation of libxumux — xumux v0.1.0-draft TypeScript library#1

Merged
visionik merged 8 commits into
mainfrom
feat/initial-implementation
May 6, 2026
Merged

feat: initial implementation of libxumux — xumux v0.1.0-draft TypeScript library#1
visionik merged 8 commits into
mainfrom
feat/initial-implementation

Conversation

@visionik
Copy link
Copy Markdown
Contributor

@visionik visionik commented May 5, 2026

Summary

Full TypeScript implementation of the xumux v0.1.0-draft transport-agnostic channel multiplexing protocol. Isomorphic — targets both Node.js and web browsers with no environment-specific APIs in shared code.

Changes

  • Codec: binary frame encoder/decoder, streaming FrameDecoder, OMUX magic, fragmentation/reassembly (spec test vectors verified)
  • Control channel: all 10 message types, lifecycle state machine, keepalive, graceful close
  • Transport adapters: WebSocket, WebRTC DataChannel, TCP, stdio, QUIC/WebTransport — tree-shakeable
  • Connection API: XumuxClient, XumuxServer, XumuxChannel (ReadableStream/WritableStream)

vBRIEF References

  • vbrief/completed/2026-05-04-project-scaffold.vbrief.json
  • vbrief/completed/2026-05-04-codec-framing.vbrief.json
  • vbrief/completed/2026-05-04-control-channel.vbrief.json
  • vbrief/completed/2026-05-04-transport-adapters.vbrief.json
  • vbrief/completed/2026-05-04-connection-api.vbrief.json
  • vbrief/completed/2026-05-04-unit-coverage-100.vbrief.json
  • vbrief/completed/2026-05-04-fuzz-tests.vbrief.json
  • vbrief/completed/2026-05-04-e2e-tests.vbrief.json
  • vbrief/completed/2026-05-05-e2e-webrtc-loopback.vbrief.json
  • vbrief/completed/2026-05-05-e2e-webrtc-node-datachannel.vbrief.json

Checklist

  • task check passes (lint + typecheck + 145 unit tests, 96.7% line coverage)
  • task e2e passes (WebSocket, TCP, stdio, WebRTC loopback + node-datachannel)
  • Commit messages follow Conventional Commits format
  • No secrets or .env content in the diff
  • New source files have corresponding tests

Testing

task test                  # 145 unit tests, 96.7% line coverage
task e2e                   # WebSocket + TCP + stdio E2E (requires task build)
task e2e:webrtc:loopback   # WebRTC loopback mock (no native deps)
task e2e:webrtc:native     # WebRTC real DTLS/SCTP via node-datachannel

visionik added 6 commits May 5, 2026 17:11
- package.json: ESM+CJS dual package, exports map, sideEffects:false
- tsconfig.json: strict TS, ES2022, DOM+DOM.Iterable for browser APIs
- tsup.config.ts: 6 entry points (index + 5 transport adapters), ESM+CJS+.d.ts
- vitest.config.ts: v8 coverage, 95% line/stmt/func thresholds, 80% branch
- vitest.e2e.config.ts: separate E2E config (30s timeout)
- eslint.config.js: ESLint 9 flat config, typescript-eslint, no-any enforced
- .prettierrc, Taskfile.yml (build/test/check/e2e tasks)
- encodeFrame: standard (6-byte) and extended (8-byte) frame encoder
- decodeFrame: single-frame parser with EXTENDED_LENGTH stripping
- FrameDecoder: streaming state machine for TCP/stdio byte streams
- fragment(): splits large messages into FRAGMENT/FRAGMENT_END frames
- Reassembler: per-channel reassembly with protocol error detection
- getMagicBytes/validateMagicBytes: OMUX (0x4F4D5558) for stream transports
- XUMUX_VERSION, OMUX_MAGIC, FrameFlags, ControlType, CloseCode constants

Implements: FR-1, FR-2, FR-3, FR-4, FR-5, FR-12
- Typed interfaces for all 10 message types (HELLO, WELCOME, OPEN_CHANNEL,
  CHANNEL_ACK, CLOSE_CHANNEL, CHANNEL_REJECT, PING, PONG, CLOSE, ERROR)
- PING/PONG: 4-byte and 8-byte big-endian binary payloads (spec test vectors)
- JSON codec for all other control messages (UTF-8)
- ControlHandler: HELLO/WELCOME handshake, dynamic channel lifecycle,
  keepalive PING/PONG with configurable interval/timeout, graceful CLOSE
- Buffered message delivery for synchronous in-memory transports
- Property-based fuzz tests: PING/PONG round-trips for all uint32 values,
  HELLO/WELCOME/OPEN_CHANNEL/CLOSE/ERROR JSON round-trips

Implements: FR-6, FR-7, FR-8, FR-9, FR-10, FR-11, FR-19
- TransportAdapter interface: { send(Uint8Array), receive(): AsyncIterable, close() }
- WebSocketAdapter: browser + Node.js, duck-typed WebSocketLike interface
- WebRTCAdapter: RTCDataChannel, duck-typed DataChannelLike interface
- TcpAdapter: Node.js net.Socket, OMUX magic + FrameDecoder streaming
- StdioAdapter: process.stdin/stdout, OMUX magic + FrameDecoder streaming
- QuicAdapter: WebTransport ReadableStream/WritableStream pair
- createInMemoryPair(): zero-network transport for unit + integration tests
- All adapters are tree-shakeable separate entry points (no browser-unsafe
  code in WebSocket/WebRTC/QUIC; TCP/stdio are Node.js only)

Implements: FR-13, FR-14, FR-15, FR-16, FR-17, NFR-1, NFR-4, NFR-5
- XumuxPeer base (EventTarget): wires codec, control handler, channel map
  - Receive loop: decodes frames, demuxes to control handler or per-channel Reassembler
  - Events: channel-open, channel-close, error, close
- XumuxClient: initiates HELLO, registers channels from WELCOME
- XumuxServer: awaits HELLO, sends WELCOME, assigns channel IDs 1-254
- XumuxChannel: { id, name, readable: ReadableStream, writable: WritableStream, state }
  - WritableStream write handler: fragments large payloads automatically
  - ReadableStream: enqueued by receive loop on reassembled payload arrival
- Public API re-exported from src/index.ts with full JSDoc + zero any types

Implements: FR-7, FR-8, FR-18, NFR-1, NFR-2, NFR-7
E2E tests exercising the full xumux stack over real I/O — no in-memory mocks.
Each transport runs 5 protocol scenarios: handshake, bidirectional data exchange,
70KB fragmented message (extended-header path), dynamic OPEN_CHANNEL, graceful CLOSE.

Transports:
- WebSocket (ws package): real server on random port, WebSocketAdapter client
- TCP (net.Socket): net.createServer, TcpAdapter + OMUX magic on wire
- stdio: spawned child process (child-server.mjs), StdioAdapter on parent side
- WebRTC loopback: in-memory DataChannelLike pair, no native deps, queueMicrotask
  delivery to avoid listener-registration race and CLOSE_ack drop after closeAll()
- WebRTC node-datachannel: real DTLS/SCTP via libdatachannel, ICE wired in-process,
  NodeDCShim bridges callback API to DataChannelLike, shim created after DC open

task e2e / task e2e:ws / task e2e:tcp / task e2e:stdio
task e2e:webrtc:loopback / task e2e:webrtc:native
@github-advanced-security
Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR introduces the full TypeScript implementation of the xumux v0.1.0-draft channel multiplexing protocol library, including the binary codec, control channel state machine, five transport adapters (WebSocket, WebRTC, TCP, stdio, QUIC), and the XumuxClient/XumuxServer/XumuxChannel connection API.

  • Codec & fragmentation: 8-byte fixed frame header encoder/decoder, streaming FrameDecoder for stream-oriented transports, and fragment/reassemble logic — all backed by spec test vectors and fuzz tests.
  • Control handler: Full 10-message lifecycle state machine (HELLO/WELCOME, OPEN_CHANNEL/CHANNEL_ACK, PING/PONG, CLOSE, ERROR) with keepalive timers and graceful close handshake.
  • Transport adapters: Five tree-shakeable adapters for WebSocket, WebRTC DataChannel, TCP, stdio, and QUIC/WebTransport, plus an in-memory pair for testing.

Confidence Score: 3/5

Two correctness defects in the control handler's close/keepalive paths need to be fixed before this ships as a stable library.

The control handler's close and keepalive paths have two concrete defects that affect every transport and caller: destroy() leaves closeResolver unresolved causing permanent promise hangs on unexpected transport EOF, and uncancelled PONG deadline timers can spuriously close healthy connections under non-default keepalive configs.

src/control/handler.ts — the destroy() method and the startKeepalive() interval callback both need fixes before the library is safe to ship.

Important Files Changed

Filename Overview
src/control/handler.ts Lifecycle state machine and keepalive logic; two defects: destroy() never resolves closeResolver (hanging peer.close() promise on unexpected transport EOF), and the PONG deadline timer handle is overwritten without cancelling the prior timer.
src/connection.ts XumuxPeer, XumuxClient, XumuxServer wiring; allocateChannelId() correctly bounds-checks, but the separate nextChannelId counter in accept() has no upper-bound guard (flagged in prior threads).
src/codec/decode-frame.ts Single-frame decoder and streaming FrameDecoder; payload length cap issue flagged in prior threads, otherwise logic is correct.
src/codec/fragment.ts Fragment/reassembly logic; accumulator memory-exhaustion issue flagged in prior threads; fragmentation logic itself is correct.
src/transports/websocket-adapter.ts WebSocket transport adapter; missing error-event listener flagged in prior threads; queue/waiter pattern is otherwise sound.
src/transports/webrtc-adapter.ts WebRTC DataChannel adapter; same message/close queue pattern as WebSocketAdapter with an unregistered error listener.
src/transports/tcp-adapter.ts TCP transport adapter with magic-byte framing; logic is correct, FrameDecoder reuse is sound.
src/transports/quic-adapter.ts QUIC/WebTransport adapter; writer acquired at construction time, released on close; minor concern about releaseLock during an in-flight close, but not a functional defect.
src/channel.ts Channel abstraction wrapping ReadableStream/WritableStream; state machine is straightforward, fragmentation delegation is correct.
src/control/codec.ts Control message codec; PING/PONG binary encoding and JSON codecs for all other message types are correctly implemented.

Sequence Diagram

sequenceDiagram
    participant App
    participant XumuxClient
    participant ControlHandler
    participant Transport
    participant XumuxServer

    App->>XumuxClient: connect(hello?)
    XumuxClient->>ControlHandler: sendHello(msg)
    ControlHandler->>Transport: send(encodeHello)
    Transport-->>XumuxServer: HELLO frame
    XumuxServer->>ControlHandler: awaitHello() resolves
    XumuxServer->>ControlHandler: sendWelcome(msg)
    ControlHandler->>Transport: send(encodeWelcome)
    Transport-->>XumuxClient: WELCOME frame
    XumuxClient-->>App: Map<name, XumuxChannel>

    loop Keepalive
        ControlHandler->>Transport: PING (every pingInterval)
        Transport-->>ControlHandler: PONG
        Note over ControlHandler: clears pongDeadline
    end

    App->>XumuxClient: close()
    XumuxClient->>ControlHandler: close(code)
    ControlHandler->>Transport: send(CLOSE)
    Transport-->>XumuxServer: CLOSE frame
    XumuxServer->>Transport: send(CLOSE echo)
    Transport-->>XumuxClient: CLOSE echo
    ControlHandler-->>App: close() resolves
Loading

Comments Outside Diff (2)

  1. src/control/handler.ts, line 910-918 (link)

    P1 destroy() leaves closeResolver dangling — peer.close() promise hangs forever

    destroy() rejects pending openChannel promises but never resolves or rejects closeResolver. If a caller does await peer.close() and the remote peer disconnects before the CLOSE echo arrives, receiveLoop detects the EOF and calls destroy(). State transitions to 'closed', but closeResolver is never called, so the promise returned by peer.close() is permanently suspended. Add this.closeResolver?.(); this.closeResolver = null; (or a rejection) inside destroy() to unblock callers.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/control/handler.ts
    Line: 910-918
    
    Comment:
    **`destroy()` leaves `closeResolver` dangling — `peer.close()` promise hangs forever**
    
    `destroy()` rejects pending `openChannel` promises but never resolves or rejects `closeResolver`. If a caller does `await peer.close()` and the remote peer disconnects before the CLOSE echo arrives, `receiveLoop` detects the EOF and calls `destroy()`. State transitions to `'closed'`, but `closeResolver` is never called, so the promise returned by `peer.close()` is permanently suspended. Add `this.closeResolver?.(); this.closeResolver = null;` (or a rejection) inside `destroy()` to unblock callers.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/control/handler.ts, line 886-895 (link)

    P1 pongDeadline timer handle overwritten without clearing the previous one

    Each interval tick unconditionally overwrites this.pongDeadline with a new setTimeout handle, discarding the reference to any prior timer. If pingInterval < pingTimeout (a valid non-default configuration), the second PING fires while the first PONG deadline is still running. The first timeout reference is lost, so stopKeepalive() and onPong() can no longer cancel it. When it fires it calls this.close() even if the PONG for the second PING already arrived and cleared the second deadline, triggering a spurious disconnect. Call clearTimeout(this.pongDeadline) before reassigning the handle.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/control/handler.ts
    Line: 886-895
    
    Comment:
    **`pongDeadline` timer handle overwritten without clearing the previous one**
    
    Each interval tick unconditionally overwrites `this.pongDeadline` with a new `setTimeout` handle, discarding the reference to any prior timer. If `pingInterval < pingTimeout` (a valid non-default configuration), the second PING fires while the first PONG deadline is still running. The first timeout reference is lost, so `stopKeepalive()` and `onPong()` can no longer cancel it. When it fires it calls `this.close()` even if the PONG for the second PING already arrived and cleared the second deadline, triggering a spurious disconnect. Call `clearTimeout(this.pongDeadline)` before reassigning the handle.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/control/handler.ts:910-918
**`destroy()` leaves `closeResolver` dangling — `peer.close()` promise hangs forever**

`destroy()` rejects pending `openChannel` promises but never resolves or rejects `closeResolver`. If a caller does `await peer.close()` and the remote peer disconnects before the CLOSE echo arrives, `receiveLoop` detects the EOF and calls `destroy()`. State transitions to `'closed'`, but `closeResolver` is never called, so the promise returned by `peer.close()` is permanently suspended. Add `this.closeResolver?.(); this.closeResolver = null;` (or a rejection) inside `destroy()` to unblock callers.

### Issue 2 of 2
src/control/handler.ts:886-895
**`pongDeadline` timer handle overwritten without clearing the previous one**

Each interval tick unconditionally overwrites `this.pongDeadline` with a new `setTimeout` handle, discarding the reference to any prior timer. If `pingInterval < pingTimeout` (a valid non-default configuration), the second PING fires while the first PONG deadline is still running. The first timeout reference is lost, so `stopKeepalive()` and `onPong()` can no longer cancel it. When it fires it calls `this.close()` even if the PONG for the second PING already arrived and cleared the second deadline, triggering a spurious disconnect. Call `clearTimeout(this.pongDeadline)` before reassigning the handle.

Reviews (3): Last reviewed commit: "docs: update README for xumux v0.2 8-byt..." | Re-trigger Greptile

Comment thread src/codec/decode-frame.ts Outdated
Comment thread src/connection.ts
Comment thread src/transports/websocket-adapter.ts
Comment thread src/connection.ts
Comment thread src/codec/fragment.ts
Comment on lines +111 to +115
this.accumulator.push(frame.payload);

if (isFragmentEnd) {
return this.flush();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Reassembler accumulates fragments without a total-size limit

The accumulator array grows indefinitely as long as frames with the FRAGMENT bit set keep arriving without FRAGMENT_END. A malicious peer can send a continuous stream of small, individually-valid fragment frames (e.g. 64 KB each) that individually satisfy any per-frame checks yet build up hundreds of megabytes in the reassembler per channel before the connection is closed. There is no check against DEFAULT_MAX_MESSAGE_SIZE or any configurable cap, so this is a distinct memory-exhaustion path from the FrameDecoder size issue already filed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/codec/fragment.ts
Line: 111-115

Comment:
**Reassembler accumulates fragments without a total-size limit**

The `accumulator` array grows indefinitely as long as frames with the `FRAGMENT` bit set keep arriving without `FRAGMENT_END`. A malicious peer can send a continuous stream of small, individually-valid fragment frames (e.g. 64 KB each) that individually satisfy any per-frame checks yet build up hundreds of megabytes in the reassembler per channel before the connection is closed. There is no check against `DEFAULT_MAX_MESSAGE_SIZE` or any configurable cap, so this is a distinct memory-exhaustion path from the `FrameDecoder` size issue already filed.

How can I resolve this? If you propose a fix, please make it concise.

@visionik visionik merged commit d276be6 into main May 6, 2026
2 checks passed
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.

2 participants