Let a signal abort a send queued behind a full send window (#90)

* Regression tests for aborting a send queued behind a full window

* Let a signal abort a send queued behind a full send window

* Log a queued send, tighten its regression tests and settle the decision record

* Take the send window's queue off an array so an abort cannot stall the loop

---------

Co-authored-by: Mikael Göransson <mikael.goransson@timewave.se>
This commit is contained in:
2026-09-06 18:17:01 +02:00
committed by GitHub
parent 0a7c491e55
commit 30eedc81ee
7 changed files with 243 additions and 17 deletions
+4 -2
View File
@@ -48,7 +48,7 @@ export class OutgoingRequests {
this.pending = new PendingRequests(options.log);
this.responseTimeout = options.responseTimeout;
this.transport = options.transport;
this.window = new SendWindow(options.maxOutstanding);
this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log });
}
/** Read through a method: a drop can land while a request is awaiting. */
@@ -115,7 +115,9 @@ export class OutgoingRequests {
if (held.err) return { err: held.err };
await this.window.acquire();
const slot = await this.window.acquire(options.signal);
if (slot.err) return { err: slot.err };
const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
+51 -9
View File
@@ -1,31 +1,50 @@
import type { SmppLog } from './log.ts';
import type { VoidResult } from './result.ts';
import { IdleWaiters } from './idle-waiters.ts';
export type SendWindowOptions = {
limit: number;
log: SmppLog;
};
type Waiter = (result: VoidResult) => void;
function aborted(): Error {
return new Error('Aborted while waiting for a send window slot');
}
/** Caps how many requests are on the wire at once; anything past the limit waits its turn. */
export class SendWindow {
private readonly idleWaiters = new IdleWaiters();
private readonly limit: number;
private readonly waiting: (() => void)[] = [];
private readonly log: SmppLog;
private readonly waiting = new Set<Waiter>();
private inFlight = 0;
constructor(limit: number) {
this.limit = limit;
constructor(options: SendWindowOptions) {
this.limit = options.limit;
this.log = options.log;
}
acquire(): Promise<void> {
/** Resolves once a slot is the caller's, or with the reason it stopped waiting for one. */
acquire(signal: AbortSignal | undefined): Promise<VoidResult> {
if (this.inFlight < this.limit) {
this.inFlight++;
return Promise.resolve();
return Promise.resolve({});
}
return new Promise<void>(resolve => this.waiting.push(resolve));
if (signal?.aborted === true) return Promise.resolve({ err: aborted() });
return this.queue(signal);
}
release(): void {
const next = this.waiting.shift();
const next = this.waiting.values().next().value;
if (next) {
next();
this.waiting.delete(next);
next({});
return;
}
@@ -39,11 +58,34 @@ export class SendWindow {
/** Everything the caller is still owed: on the wire, plus queued behind a full window. */
unfinished(): number {
return this.inFlight + this.waiting.length;
return this.inFlight + this.waiting.size;
}
/** Resolves 0 once nothing is left on the wire, or with what still is. */
idle(timeout: number, signal: AbortSignal | undefined): Promise<number> {
return this.idleWaiters.wait(() => this.unfinished(), timeout, signal);
}
/** A waiter leaves the queue as it settles, so release() can only hand a slot to one still in it. */
private queue(signal: AbortSignal | undefined): Promise<VoidResult> {
this.log.verbose('sendWindow - queueing a request behind a full window', {
limit: this.limit,
queued: this.waiting.size + 1,
});
return new Promise<VoidResult>(resolve => {
const settle = (result: VoidResult): void => {
this.waiting.delete(settle);
signal?.removeEventListener('abort', onAbort);
resolve(result);
};
function onAbort(): void {
settle({ err: aborted() });
}
signal?.addEventListener('abort', onAbort, { once: true });
this.waiting.add(settle);
});
}
}
+1 -1
View File
@@ -136,7 +136,7 @@ export const defaults = {
/**
* A count below 1 does not fail loudly anywhere downstream: `maxOutstanding: 0` leaves every send
* queued behind a slot that is never freed, so the call never settles at all.
* queued behind a slot that is never freed, so a send with no `signal` never settles at all.
*/
export function checkSessionOptions(options: CheckableOptions): VoidResult {
if (options.fromStart !== undefined) {