Skip to content
Open
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
2 changes: 1 addition & 1 deletion scripts/lib/lactoserv/dev
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand Down
3 changes: 2 additions & 1 deletion src/net-protocol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
},
"imports": {
"#x/*": "./export/*.js",
"#p/*": "./private/*.js"
"#p/*": "./private/*.js",
"#tests/*": "./tests/*.js"
},

"dependencies": {
Expand Down
16 changes: 14 additions & 2 deletions src/net-protocol/private/HttpWrangler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
16 changes: 14 additions & 2 deletions src/net-protocol/private/HttpsWrangler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
51 changes: 51 additions & 0 deletions src/net-protocol/private/TcpWrangler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -340,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}.
Expand Down
186 changes: 186 additions & 0 deletions src/net-protocol/tests/TcpWrangler.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// 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_unused) {
// @emptyBlock
}

/** @override */
async _impl_newConnection(_context_unused) {
// @emptyBlock
}

/**
* Exposes the protected method for testing.
*
* @param {number} gracePeriodMsec Grace period.
* @returns {Promise<void>} Resolves when complete.
*/
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();
});
});
Loading