Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/node/hooks/express/socketio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ const socketSessionMiddleware = (args: any) => (socket: any, next: Function) =>
};

export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {
// Engine.io WebSocket transport-level packing (ether/etherpad#7756 lever 8).
// Apply BEFORE constructing the socket.io Server so the patched transport
// prototype is in effect when the Server instantiates its engine.
if (settings.enginePacking === true) {
// Require lazily so production deployments that don't set the flag
// don't even load the patch module.
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('../../utils/EnginePacking').installEngineWsPacking();
}

// init socket.io and redirect all requests to the MessageHandler
// there shouldn't be a browser that isn't compatible to all
// transports in this list at once
Expand Down
86 changes: 86 additions & 0 deletions src/node/utils/EnginePacking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Engine.io WebSocket transport-level packing — #7756 lever 8 prototype.
//
// Issue (see ether/etherpad#7767): engine.io's WebSocket transport sends one
// WS frame per engine.io packet, even when the engine.io socket has multiple
// packets buffered. The polling transport already coalesces — `send(packets)`
// goes through `encodePayload(packets)` and writes one HTTP response
// containing the whole payload. Under high emit rate the WS path is dominated
// by per-frame syscall overhead on the server and per-message callback
// overhead on the client.
//
// This module monkey-patches engine.io's WebSocket transport prototype so
// `send(packets)` with N > 1 packets goes through `encodePayload` and emits
// ONE WS frame containing the multi-packet payload. The frame contents are
// the same wire bytes the polling transport already uses, just delivered as
// a single WebSocket message instead of one frame per packet.
//
// Server-side only. The receiving client (engine.io-client, or anything
// reading the WS frames) must detect the engine.io-parser record separator
// (`\x1e`, U+001E) and call `decodePayload` instead of `decodePacket` when
// it's present. Newly-built clients (browser bundle + etherpad-cli-client
// patched separately) are forward-compatible: a single-packet frame never
// contains a raw `\x1e` (JSON escapes it to ``, and engine.io packet
// type bytes are '0'-'6' or empty for binary).
//
// Gated by settings.enginePacking. Production deployments are not affected
// by default. Enabling it without a forward-compatible client will silently
// break clients that receive a payload-encoded frame.

import log4js from 'log4js';

const logger = log4js.getLogger('engine-packing');

let installed = false;

/** Apply the patch once. Subsequent calls are no-ops. Idempotent so the
* module can be required from multiple boot paths without double-wrapping. */
export const installEngineWsPacking = (): void => {
if (installed) return;
installed = true;

let WebSocketTransport: any;
let encodePayload: any;
try {
// Resolve from inside engine.io's own dependency closure so we pick up
// exactly the engine.io-parser the transport uses, not a duplicate copy.
WebSocketTransport = require('engine.io/build/transports/websocket').WebSocket;
encodePayload = require('engine.io-parser').encodePayload;
} catch (err: any) {
logger.warn(`Unable to install engine.io WS packing (modules not found): ${err && err.message || err}`);
return;
}
if (typeof WebSocketTransport !== 'function' ||
typeof WebSocketTransport.prototype !== 'object' ||
typeof encodePayload !== 'function') {
logger.warn('engine.io shape is unexpected; skipping WS packing patch');
return;
}

const originalSend = WebSocketTransport.prototype.send;

WebSocketTransport.prototype.send = function (packets: any[]) {
// Single-packet sends keep the legacy fast path: per-frame encoding
// including the pre-encoded-frame optimisation. Only fan-out bursts
// (writeBuffer accumulated more than one packet between flushes) are
// packed — for the steady state of a quiet pad, behaviour is identical
// to the upstream implementation.
if (!Array.isArray(packets) || packets.length < 2) {
return originalSend.call(this, packets);
}

this.writable = false;
const self = this;
encodePayload(packets, (data: string) => {
// Send the whole payload as ONE WS frame and fire the drain/ready
// callbacks the upstream transport sends on the last packet. The
// socket.io socket relies on `drain` to start its next flush.
try {
self.socket.send(data, self._onSentLast);
} catch (err: any) {
self.onError('write error', err && err.stack ? err.stack : err);
}
});
};

logger.info('engine.io WebSocket transport-level packing enabled (#7756 lever 8)');
};
18 changes: 18 additions & 0 deletions src/node/utils/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export type SettingsType = {
automaticReconnectionTimeout: number,
loadTest: boolean,
scalingDiveMetrics: boolean,
enginePacking: boolean,
dumpOnUncleanExit: boolean,
indentationOnNewLine: boolean,
logconfig: any | null,
Expand Down Expand Up @@ -658,6 +659,23 @@ const settings: SettingsType = {
* production deployments aren't paying for instrumentation they don't use.
*/
scalingDiveMetrics: false,
/**
* Pack multiple engine.io packets into a single WebSocket frame
* (ether/etherpad#7756 lever 8). engine.io's WS transport otherwise
* sends one frame per packet, while the polling transport already
* batches via encodePayload. Enabling this matches the polling
* coalescing behaviour on the WS path; at high fan-out rates it cuts
* the WS frame count proportionally to packets-per-flush.
*
* WARNING: enabling this requires connected clients to recognise
* payload-encoded WS frames (split on the `\x1e` record separator
* and decodePayload). Clients that pass each frame straight to
* decodePacket will fail to parse a multi-packet frame and silently
* miss those messages. The etherpad browser bundle and
* etherpad-cli-client are forward-compatible (they detect the
* separator); third-party clients may not be. Coordinate rollouts.
*/
enginePacking: false,
Comment on lines +662 to +678
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. enginepacking undocumented in template 📘 Rule violation ⚙ Maintainability

A new public config setting enginePacking is added and wired into server boot, but the user-facing
configuration documentation/template is not updated to include it. This risks operators being
unaware of the flag and its compatibility warning.
Agent Prompt
## Issue description
A new configuration option (`enginePacking`) was introduced and is used at runtime, but it is not documented in the repository’s configuration documentation, so users will not discover it or its rollout warnings.

## Issue Context
`enginePacking` is added to `SettingsType` and defaulted to `false` with detailed inline comments, and it gates a runtime behavior change in socket.io/engine.io startup.

## Fix Focus Areas
- settings.json.template[709-740]
- src/node/utils/Settings.ts[662-678]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

/**
* Disable dump of objects preventing a clean exit
*/
Expand Down
Loading