From d32d857eb31c677a5a1725cabd87eb7dd95c6406 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:13:24 +0200 Subject: [PATCH] Re-apply debug dump ignore flag on control connection resume (#1035) handleDebugDumpWsRequest sets clusterio_ignore_dump on the current socket so the WsServer send wrapper does not echo the dumper's own frames back into debugEvents. The flag was only set on the connector "connect" event, but WsServerConnector.continue() installs a fresh socket and emits "resume", not "connect", so a resumed session lost the flag. After a resume the next outgoing frame was echoed to debugEvents, ws_dumper sent a DebugWsMessageEvent, the wrapper echoed that too, and so on: infinite synchronous recursion ending in a RangeError: Invalid string length from JSON.stringify in link.ts sendEvent. That uncaught error made handleUnhandledErrors exit the controller. Repro: admin sends DebugDumpWsRequest, the control connection drops and resumes (e.g. a network blip), controller crashes on resume. Set the flag on "resume" as well as "connect". Co-authored-by: Claude Opus 4.8 Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> --- packages/controller/src/ControlConnection.ts | 10 ++++-- test/controller/ControlConnection.test.js | 34 +++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 60f3e263..3a3af9f4 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -48,9 +48,13 @@ export default class ControlConnection extends BaseConnection { this._version = registerData.version; - this.connector.on("connect", () => { - this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper); - }); + // A resume installs a fresh socket, so re-apply the ignore flag or the + // dumper's own frames get echoed back into it and recurse forever. + for (let event of ["connect", "resume"] as const) { + this.connector.on(event, () => { + this.connector._socket!.clusterio_ignore_dump = Boolean(this.ws_dumper); + }); + } this.connector.on("close", () => { if (this.logTransport) { this._controller.clusterLogger.remove(this.logTransport); diff --git a/test/controller/ControlConnection.test.js b/test/controller/ControlConnection.test.js index b97c5527..d82f6cc9 100644 --- a/test/controller/ControlConnection.test.js +++ b/test/controller/ControlConnection.test.js @@ -1,12 +1,44 @@ import assert from "node:assert/strict"; import events from "node:events"; import * as lib from "@clusterio/lib"; -import { ControlConnection, ControllerHooks } from "@clusterio/controller"; +import { Controller, ControlConnection, ControllerHooks } from "@clusterio/controller"; import { MockConnector, MockLogger } from "../mock.js"; const addr = lib.Address.fromShorthand; describe("controller/src/ControlConnection", function() { + describe(".handleDebugDumpWsRequest()", function() { + function makeConnection() { + const controllerConfig = new lib.ControllerConfig("controller"); + const connector = new lib.VirtualConnector( + lib.Address.fromShorthand("controller"), + lib.Address.fromShorthand({ controlId: 1 }), + ); + connector._socket = {}; + const controller = new Controller(lib.logger, [], controllerConfig); + const user = controller.users.getOrCreateUser("test"); + return new ControlConnection({ version: "2.0.0" }, connector, controller, user, 1); + } + + it("re-applies clusterio_ignore_dump to the socket on resume", async function() { + const connection = makeConnection(); + await connection.handleDebugDumpWsRequest(new lib.DebugDumpWsRequest()); + // A resume installs a fresh socket without the flag. + const resumedSocket = {}; + connection.connector._socket = resumedSocket; + connection.connector.emit("resume"); + assert.equal(resumedSocket.clusterio_ignore_dump, true); + }); + + it("leaves the flag off on resume without a dumper", function() { + const connection = makeConnection(); + const resumedSocket = {}; + connection.connector._socket = resumedSocket; + connection.connector.emit("resume"); + assert.equal(resumedSocket.clusterio_ignore_dump, false); + }); + }); + describe(".handleControllerRestartRequest()", function() { let mockController;