From 78db183018f5e83bdc025e2033440333578a2350 Mon Sep 17 00:00:00 2001 From: Jeremy Bornstein Date: Tue, 13 Jan 2026 05:53:53 +1100 Subject: [PATCH 1/4] Allow Node 25 in runner version check --- scripts/lib/lactoserv/dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lib/lactoserv/dev b/scripts/lib/lactoserv/dev index 836f2377b..7ae6b366a 100755 --- a/scripts/lib/lactoserv/dev +++ b/scripts/lib/lactoserv/dev @@ -65,7 +65,7 @@ function target-build { && env-minimize \ && lib node-project build-main-module \ --out="${outDir}" --modules-dirs="${srcDir}" \ - --runner-script=run --runner-versions[]='20 21 22 23 24' \ + --runner-script=run --runner-versions[]='20 21 22 23 24 25' \ lactoserv ) } From 339f2eaea108d15a471b26e80af0dcaea6c53dc7 Mon Sep 17 00:00:00 2001 From: Jeremy Bornstein Date: Tue, 13 Jan 2026 07:01:55 +1100 Subject: [PATCH 2/4] Force-close connections during HTTP/HTTPS server shutdown Previously, HTTP and HTTPS wranglers would close idle connections during shutdown but leave active connections to timeout naturally (up to 3 minutes). This adds a 250ms grace period after which remaining connections are force-destroyed, matching HTTP2 behavior. --- src/net-protocol/private/HttpWrangler.js | 16 +++++++++-- src/net-protocol/private/HttpsWrangler.js | 16 +++++++++-- src/net-protocol/private/TcpWrangler.js | 33 +++++++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/net-protocol/private/HttpWrangler.js b/src/net-protocol/private/HttpWrangler.js index c899b2fcb..c7ab491f1 100644 --- a/src/net-protocol/private/HttpWrangler.js +++ b/src/net-protocol/private/HttpWrangler.js @@ -43,7 +43,19 @@ export class HttpWrangler extends TcpWrangler { this.#protocolServer.close(); this.#protocolServer.closeIdleConnections(); - // TODO: Consider tracking connections and forcing things closed after a - // timeout, similar to what's done with HTTP2. + await this._prot_closeSocketsWithGracePeriod(HttpWrangler.#STOP_GRACE_PERIOD_MSEC); } + + + // + // Static members + // + + /** + * How long in msec to wait when stopping, after closing idle connections, + * before force-closing remaining connections. + * + * @type {number} + */ + static #STOP_GRACE_PERIOD_MSEC = 250; } diff --git a/src/net-protocol/private/HttpsWrangler.js b/src/net-protocol/private/HttpsWrangler.js index c3e8632f6..19af84723 100644 --- a/src/net-protocol/private/HttpsWrangler.js +++ b/src/net-protocol/private/HttpsWrangler.js @@ -49,7 +49,19 @@ export class HttpsWrangler extends TcpWrangler { this.#protocolServer.close(); this.#protocolServer.closeIdleConnections(); - // TODO: Consider tracking connections and forcing things closed after a - // timeout, similar to what's done with HTTP2. + await this._prot_closeSocketsWithGracePeriod(HttpsWrangler.#STOP_GRACE_PERIOD_MSEC); } + + + // + // Static members + // + + /** + * How long in msec to wait when stopping, after closing idle connections, + * before force-closing remaining connections. + * + * @type {number} + */ + static #STOP_GRACE_PERIOD_MSEC = 250; } diff --git a/src/net-protocol/private/TcpWrangler.js b/src/net-protocol/private/TcpWrangler.js index 7b6fa04de..687746005 100644 --- a/src/net-protocol/private/TcpWrangler.js +++ b/src/net-protocol/private/TcpWrangler.js @@ -110,6 +110,39 @@ export class TcpWrangler extends ProtocolWrangler { throw Methods.abstract(context); } + /** + * Waits for all sockets to close, up to a grace period. After the grace + * period expires, any remaining sockets are force-destroyed. This is intended + * to be called by subclasses during shutdown. + * + * @param {number} gracePeriodMsec Time in milliseconds to wait before + * force-closing remaining sockets. + */ + async _prot_closeSocketsWithGracePeriod(gracePeriodMsec) { + if (this.#sockets.size === 0) { + return; + } + + this.logger?.waitingForSocketClose(this.#sockets.size); + + await PromiseUtil.race([ + this.#anySockets.whenFalse(), + WallClock.waitForMsec(gracePeriodMsec) + ]); + + if (this.#sockets.size === 0) { + return; + } + + this.logger?.forceClosingSockets(this.#sockets.size); + + for (const socket of this.#sockets) { + if (!socket.destroyed) { + socket.destroy(); + } + } + } + /** @override */ async _impl_socketStart() { await this.#runner.start(); From 38a5afd0d9b9959c59590e6d23c0ea3912f47568 Mon Sep 17 00:00:00 2001 From: Jeremy Bornstein Date: Wed, 14 Jan 2026 12:10:35 +1100 Subject: [PATCH 3/4] Add unit tests for _prot_closeSocketsWithGracePeriod. Address review feedback requesting testability for the HTTP connection shutdown logic. Add _testing_addSocket() method to TcpWrangler to enable injecting mock sockets for testing. Create TcpWrangler.test.js with tests covering: - Immediate return when no sockets present - Early return when sockets close before grace period - Force-destroy of lingering sockets after grace period - Handling mix of early and late socket closures - Skip of already-destroyed sockets --- src/net-protocol/package.json | 3 +- src/net-protocol/private/TcpWrangler.js | 18 ++ src/net-protocol/tests/TcpWrangler.test.js | 185 +++++++++++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 src/net-protocol/tests/TcpWrangler.test.js diff --git a/src/net-protocol/package.json b/src/net-protocol/package.json index dfc519099..49997c1b2 100644 --- a/src/net-protocol/package.json +++ b/src/net-protocol/package.json @@ -10,7 +10,8 @@ }, "imports": { "#x/*": "./export/*.js", - "#p/*": "./private/*.js" + "#p/*": "./private/*.js", + "#tests/*": "./tests/*.js" }, "dependencies": { diff --git a/src/net-protocol/private/TcpWrangler.js b/src/net-protocol/private/TcpWrangler.js index 687746005..a500f03c4 100644 --- a/src/net-protocol/private/TcpWrangler.js +++ b/src/net-protocol/private/TcpWrangler.js @@ -373,6 +373,24 @@ export class TcpWrangler extends ProtocolWrangler { return connLogger; } + /** + * Adds a socket to the internal tracking set. This is for testing purposes + * only. + * + * @param {object} socket The socket to add. + */ + _testing_addSocket(socket) { + this.#sockets.add(socket); + this.#anySockets.value = true; + + socket.once('close', () => { + this.#sockets.delete(socket); + if (this.#sockets.size === 0) { + this.#anySockets.value = false; + } + }); + } + /** * Runs the low-level stack. This is called as the main function of the * {@link #runner}. diff --git a/src/net-protocol/tests/TcpWrangler.test.js b/src/net-protocol/tests/TcpWrangler.test.js new file mode 100644 index 000000000..a09645af1 --- /dev/null +++ b/src/net-protocol/tests/TcpWrangler.test.js @@ -0,0 +1,185 @@ +// Copyright 2022-2025 the Lactoserv Authors (Dan Bornstein et alia). +// SPDX-License-Identifier: Apache-2.0 + +import { EventEmitter } from 'node:events'; +import { setImmediate } from 'node:timers/promises'; + +import { PromiseState } from '@this/async'; +import { InterfaceAddress } from '@this/net-util'; + +import { TcpWrangler } from '#p/TcpWrangler'; + + +/** + * Mock socket for testing, providing the minimal interface needed. + */ +class MockSocket extends EventEmitter { + /** @type {boolean} */ + destroyed = false; + + /** + * Destroys the socket. + */ + destroy() { + if (!this.destroyed) { + this.destroyed = true; + this.emit('close'); + } + } +} + +/** + * Minimal concrete subclass of TcpWrangler for testing. + */ +class TestTcpWrangler extends TcpWrangler { + /** @override */ + get _impl_infoForLog() { + return { test: true }; + } + + /** @override */ + async _impl_init() { + // @emptyBlock + } + + /** @override */ + async _impl_serverStart() { + // @emptyBlock + } + + /** @override */ + async _impl_serverStop(_willReload) { + // @emptyBlock + } + + /** @override */ + async _impl_newConnection(_context) { + // @emptyBlock + } + + /** + * Exposes the protected method for testing. + * + * @param {number} gracePeriodMsec Grace period. + */ + async closeSocketsWithGracePeriod(gracePeriodMsec) { + return this._prot_closeSocketsWithGracePeriod(gracePeriodMsec); + } + + /** + * Adds a socket for testing. + * + * @param {object} socket The socket to add. + */ + addSocket(socket) { + this._testing_addSocket(socket); + } +} + +/** + * Creates a TestTcpWrangler instance. + * + * @returns {TestTcpWrangler} The instance. + */ +function makeWrangler() { + const iface = new InterfaceAddress('*:8080'); + return new TestTcpWrangler({ + interface: iface, + protocol: 'http', + requestHandler: { handleRequest: () => null } + }); +} + + +describe('_prot_closeSocketsWithGracePeriod()', () => { + test('returns immediately when there are no sockets', async () => { + const wrangler = makeWrangler(); + const result = wrangler.closeSocketsWithGracePeriod(1000); + + // Should resolve immediately + await setImmediate(); + expect(PromiseState.isFulfilled(result)).toBeTrue(); + await result; + }); + + test('returns immediately when all sockets close before grace period', async () => { + const wrangler = makeWrangler(); + const socket = new MockSocket(); + wrangler.addSocket(socket); + + const result = wrangler.closeSocketsWithGracePeriod(1000); + + // Should be pending initially + await setImmediate(); + expect(PromiseState.isPending(result)).toBeTrue(); + + // Close the socket + socket.destroy(); + await setImmediate(); + + // Should now be fulfilled + expect(PromiseState.isFulfilled(result)).toBeTrue(); + await result; + }); + + test('force-destroys sockets after grace period expires', async () => { + const wrangler = makeWrangler(); + const socket = new MockSocket(); + wrangler.addSocket(socket); + + expect(socket.destroyed).toBeFalse(); + + // Use a very short grace period for testing + const result = wrangler.closeSocketsWithGracePeriod(10); + + // Wait for grace period to expire + await result; + + // Socket should have been force-destroyed + expect(socket.destroyed).toBeTrue(); + }); + + test('handles multiple sockets, some closing before and some after grace period', async () => { + const wrangler = makeWrangler(); + const socket1 = new MockSocket(); + const socket2 = new MockSocket(); + wrangler.addSocket(socket1); + wrangler.addSocket(socket2); + + // Start with short grace period + const result = wrangler.closeSocketsWithGracePeriod(50); + + // Close socket1 immediately + socket1.destroy(); + expect(socket1.destroyed).toBeTrue(); + expect(socket2.destroyed).toBeFalse(); + + // Wait for grace period to expire + await result; + + // socket2 should have been force-destroyed + expect(socket2.destroyed).toBeTrue(); + }); + + test('does not re-destroy already-destroyed sockets', async () => { + const wrangler = makeWrangler(); + const socket = new MockSocket(); + wrangler.addSocket(socket); + + // Pre-destroy the socket but don't emit close + socket.destroyed = true; + + let destroyCalled = false; + const originalDestroy = socket.destroy.bind(socket); + socket.destroy = () => { + destroyCalled = true; + originalDestroy(); + }; + + // Use short grace period + await wrangler.closeSocketsWithGracePeriod(10); + + // destroy() should not have been called since socket was already destroyed + expect(destroyCalled).toBeFalse(); + }); +}); From 48d247e6a3b25358d6c0331d778f930b46e6f798 Mon Sep 17 00:00:00 2001 From: Jeremy Bornstein Date: Wed, 14 Jan 2026 12:17:58 +1100 Subject: [PATCH 4/4] Fix linter errors in TcpWrangler test. --- src/net-protocol/tests/TcpWrangler.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/net-protocol/tests/TcpWrangler.test.js b/src/net-protocol/tests/TcpWrangler.test.js index a09645af1..9c09ebce9 100644 --- a/src/net-protocol/tests/TcpWrangler.test.js +++ b/src/net-protocol/tests/TcpWrangler.test.js @@ -48,12 +48,12 @@ class TestTcpWrangler extends TcpWrangler { } /** @override */ - async _impl_serverStop(_willReload) { + async _impl_serverStop(_willReload_unused) { // @emptyBlock } /** @override */ - async _impl_newConnection(_context) { + async _impl_newConnection(_context_unused) { // @emptyBlock } @@ -61,6 +61,7 @@ class TestTcpWrangler extends TcpWrangler { * Exposes the protected method for testing. * * @param {number} gracePeriodMsec Grace period. + * @returns {Promise} Resolves when complete. */ async closeSocketsWithGracePeriod(gracePeriodMsec) { return this._prot_closeSocketsWithGracePeriod(gracePeriodMsec);