Keep the process alive while a send waits for a link

This commit is contained in:
2026-09-01 10:38:16 +02:00
parent 58817eebbf
commit 2c0dfd172f
7 changed files with 65 additions and 24 deletions
+27 -9
View File
@@ -1,6 +1,8 @@
import type { SmppLog } from './log.ts';
import type { VoidResult } from './result.ts';
export type LinkGateOptions = {
log: SmppLog;
now?: (() => number) | undefined;
/** How long a request may wait for a link. 0 waits for as long as one may still arrive. */
timeout: number;
@@ -20,11 +22,9 @@ function over(): Error {
return new Error('Session is closed');
}
/**
* Where a request with no link to go out on waits for the next one. The owner reports what became of
* the link; nothing here reads back into the owner to find out.
*/
/** Where a request with no link to go out on waits for the next one. */
export class LinkGate {
private readonly log: SmppLog;
private readonly now: () => number;
private readonly timeout: number;
private readonly waiting = new Set<Waiter>();
@@ -32,6 +32,7 @@ export class LinkGate {
private up = true;
constructor(options: LinkGateOptions) {
this.log = options.log;
this.now = options.now ?? Date.now;
this.timeout = options.timeout;
}
@@ -41,6 +42,11 @@ export class LinkGate {
return this.up;
}
/** Why the gate will never admit a request, or undefined while one may still get through. */
refusal(): Error | undefined {
return this.up || this.returning ? undefined : over();
}
/** When a hold starting now has to give up. 0 never does. */
deadline(): number {
return this.timeout > 0 ? this.now() + this.timeout : 0;
@@ -50,6 +56,11 @@ export class LinkGate {
open(): void {
this.up = true;
this.returning = false;
if (this.waiting.size > 0) {
this.log.verbose('linkGate - sending what was held for a link', { held: this.waiting.size });
}
this.release({});
}
@@ -65,7 +76,9 @@ export class LinkGate {
wait(deadline: number, signal: AbortSignal | undefined): Promise<VoidResult> {
if (this.up) return Promise.resolve({});
if (!this.returning) return Promise.resolve({ err: over() });
const refused = this.refusal();
if (refused) return Promise.resolve({ err: refused });
if (signal?.aborted === true) return Promise.resolve({ err: aborted() });
@@ -77,6 +90,8 @@ export class LinkGate {
}
private hold(left: number, signal: AbortSignal | undefined): Promise<VoidResult> {
this.log.verbose('linkGate - holding a request until a link is back', { timeout: left });
return new Promise<VoidResult>(resolve => {
let timer: NodeJS.Timeout | undefined = undefined;
const settle = (result: VoidResult): void => {
@@ -86,15 +101,18 @@ export class LinkGate {
this.waiting.delete(settle);
resolve(result);
};
const giveUp = (): void => {
this.log.warn('linkGate - no link came back in time', { timeout: left });
settle({ err: expired() });
};
function onAbort(): void {
settle({ err: aborted() });
}
if (left > 0) {
timer = setTimeout(() => { settle({ err: expired() }); }, left);
timer.unref();
}
// Deliberately not unref()'d: a held request is awaited with a destroyed socket and no
// other handle, so an unref'd timer lets the process exit without ever settling it.
if (left > 0) timer = setTimeout(giveUp, left);
signal?.addEventListener('abort', onAbort, { once: true });
this.waiting.add(settle);
+1 -1
View File
@@ -33,7 +33,7 @@ export type SendSmsResult = {
err?: Error;
pduObjs: PduObject[];
smsIds: string[];
/** Segments the link dropped under. The peer may have taken them, so sending again may duplicate. */
/** Segments that went out unanswered. The peer may have taken them, so sending again may duplicate. */
unanswered: number;
};
+5 -5
View File
@@ -121,7 +121,7 @@ export class Session extends EventEmitter<SessionEvents> {
max: defaults.maxDlrMerges,
timeout: defaults.dlrMergeTimeout,
});
this.gate = new LinkGate({ timeout: options.responseTimeout ?? defaults.responseTimeout });
this.gate = new LinkGate({ log: this.log, timeout: options.responseTimeout ?? defaults.responseTimeout });
this.incoming = new IncomingRequests({
dlrMerger: this.dlrMerger,
log: this.log,
@@ -174,7 +174,7 @@ export class Session extends EventEmitter<SessionEvents> {
if (refused) return { err: refused };
// A bind is what makes a link usable, so it cannot wait for one. The door unbind() uses too.
// A bind is what makes a link usable, so it cannot wait for one.
if (bindCommands.includes(input.cmdName)) return (await this.attempt(input, options)).result;
const deadline = this.gate.deadline();
@@ -189,7 +189,6 @@ export class Session extends EventEmitter<SessionEvents> {
const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
// Nothing reached the socket, so the next link carries it instead of the caller resending.
// The gate's own answer, or a loop round one that admits everything spins on a dead socket.
if (!attempt.retryOnNextLink || this.gate.isUp() || !this.retrying()) return attempt.result;
}
}
@@ -206,7 +205,8 @@ export class Session extends EventEmitter<SessionEvents> {
// Before the gate and the window, or an aborted call waits for what it will never use.
if (options.signal?.aborted === true) return abortedBeforeSend();
return undefined;
// A bind skips the gate below, so the answer it would have given is given here instead.
return bindCommands.includes(input.cmdName) ? this.gate.refusal() : undefined;
}
/** Read through a method: a drop can land while a send is awaiting. */
@@ -338,9 +338,9 @@ export class Session extends EventEmitter<SessionEvents> {
}
this.resetTimers();
this.gate.open();
this.log.info('session - reconnected');
this.emit('reconnected');
this.gate.open();
return {};
}