Skip to content

Perfect-NIO 0.2.0

Latest

Choose a tag to compare

@taplin taplin released this 26 Oct 13:47
f5df3c8

Perfect-NIO 0.2.0 puts limits on upgraded WebSocket connections. In 0.1.0 a single client could make the server hold a message of any size, a client that went silent kept its connection open forever, and a message over 16 KiB sent in one frame (as browsers send them) was refused. This release fixes all three. It's a minor release because two of the fixes change behaviour; see "Breaking and behaviour changes".

.package(url: "https://github.com/PerfectlySoft/Perfect-NIO.git", from: "0.2.0")

from: "0.1.0" also resolves 0.2.0. Use .upToNextMinor(from: "0.1.0") to stay on 0.1.x.

Requirements

Unchanged from 0.1.0: Swift 6.2 or later, macOS 12+ or Linux (with pkg-config and zlib1g-dev).

Highlights

  • WebSocket message size limit (#20). Server.maxWebSocketMessageSize (default Server.defaultMaxWebSocketMessageSize, 1 MiB) caps a message whether it arrives in one frame or in fragments. A route can lower it with the new WebSocketOption.maxMessageSize(_:), but not raise it. A larger message ends the connection with close code 1009 (message too big), and readMessage() returns .close from then on.
  • WebSocket keepalive (#20). .pingInterval(_:) and .responseTimeout(_:), reserved in 0.1.0, now work. After pingInterval seconds without a frame from the client (default 30), the server sends a ping. If no frame arrives within responseTimeout seconds (default 30), it closes the connection. Any frame resets the clock, browsers answer pings automatically, and handlers never see the pong that answers the keepalive's own ping. .pingInterval(0) turns it off.

Breaking and behaviour changes

  • Messages over 1 MiB end the connection. In 0.1.0 fragmented messages had no limit, and single frames were refused above 16 KiB. Upgrade: raise Server(maxWebSocketMessageSize:) for endpoints that take larger messages.
  • Single frames up to the limit are accepted. A message over 16 KiB sent in one frame used to get close code 1009; it's now accepted up to maxWebSocketMessageSize. Upgrade: nothing.
  • Silent WebSocket clients are disconnected. An upgraded connection whose client sends nothing is pinged after 30 s and closed about 30 s later if it still sends nothing. Frames are only read while the handler calls readMessage(), so a handler that stops reading for longer than pingInterval + responseTimeout can be closed too. Upgrade: pass .pingInterval(0) to keep the 0.1.0 behaviour, or larger values for clients that are slow to respond.
  • WebSocketOption has a new case, maxMessageSize. Upgrade: an exhaustive switch over WebSocketOption outside the package needs a default:.
  • Additive API: Server.maxWebSocketMessageSize, Server.defaultMaxWebSocketMessageSize, the defaulted maxWebSocketMessageSize: argument on Server.init, and WebSocketOption.defaultPingInterval / defaultResponseTimeout. Existing calls compile unchanged.

Fixes

All in #20, against 0.1.0:

  • Memory exhaustion through fragmented messages. Continuation frames were appended without any total limit. A probe delivered a 65,536,000-byte message from one connection in 16 KB fragments. Reassembly now stops at the limit, drops what it gathered and closes with 1009.
  • Messages over 16 KiB in a single frame were refused. The upgraders used NIO's default maxFrameSize (16 KiB), so a single 20,000-byte frame was answered with close code 1009. The frame cap now follows maxWebSocketMessageSize.
  • Upgraded connections never timed out. The idle timeout is removed on upgrade, and the options meant to replace it were no-ops.
  • A huge limit would trap. NIO requires maxFrameSize <= UInt32.max, so the server clamps maxWebSocketMessageSize to between 125 bytes and UInt32.max.

Tests: a new WebSocketLimitTests suite (9 tests) covers:

  • option resolution;
  • a 100,000-byte single-frame message;
  • fragmented messages at and over a route's limit;
  • a single frame over a route's limit;
  • the server limit, for single frames and for fragments;
  • clamping;
  • a silent client being pinged and closed;
  • an answering client staying open without its handler seeing the keepalive pong;
  • the keepalive turned off.

With the reassembly limit and the keepalive removed, the reassembly, server-limit and both keepalive tests fail.

Verified: Pull request CI passed all 16 checks (Linux Swift 6.2 and 6.4, macOS Xcode 26.0.1 and 26.6, debug and release, warnings as errors, live MySQL), and CI on main (f5df3c8) passed all 8 jobs. Locally on macOS, all 118 + 2 Swift Testing tests and the XCTest suites pass, and a release build with -enable-testing compiles. On Linux with swift:6.2-noble and swift:6.4-noble, all 120 Swift Testing tests and the XCTest suites pass.

Known limitations

Everything listed for 0.1.0 still applies, plus:

  • Messages are buffered in memory. Each open WebSocket connection can hold up to maxWebSocketMessageSize while a message arrives. Total memory scales with the number of connections.
  • No close status in the API. .close carries no status code or reason in either direction. Only the server's own 1009 sends a code.
  • Text isn't validated. Invalid UTF-8 in a text message is decoded with replacement characters rather than closing with 1007.
  • A stray continuation frame (one with no message in progress) is treated as the start of a binary message rather than as a protocol error.
  • The pre-macOS 13 upgrade pipeline has the same changes, but, as in 0.1.0, only unit tests cover it.

Pull requests

  • #20: WebSocket message size limit and keepalive.