Skip to content

Commit b7c0143

Browse files
committed
test: cover abort signal cleanup paths
1 parent 1b3b990 commit b7c0143

8 files changed

Lines changed: 443 additions & 170 deletions

File tree

src/host/hostMain.ts

Lines changed: 1 addition & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
addAbortListener,
5454
makeAbortError,
5555
throwIfAborted,
56+
waitForScopedOperation,
5657
} from '../util/abort.js';
5758
import { invariant } from '../util/assert.js';
5859
import { ResourceScope } from '../util/resourceScope.js';
@@ -106,69 +107,6 @@ function rethrowAsync(error: unknown): void {
106107
});
107108
}
108109

109-
async function waitForScopedOperation<T>(params: {
110-
readonly operationName: string;
111-
readonly operation: Promise<T>;
112-
readonly scope: ResourceScope;
113-
readonly signal: AbortSignal;
114-
readonly timeoutMs?: number;
115-
readonly timeoutResult?: () => T;
116-
}): Promise<T> {
117-
const { operationName, operation, scope, signal, timeoutMs, timeoutResult } =
118-
params;
119-
invariant(operationName.length > 0, 'operationName must not be empty');
120-
if (signal.aborted) {
121-
await scope.close();
122-
throw makeAbortError(signal);
123-
}
124-
125-
const { promise, reject, resolve } = Promise.withResolvers<T>();
126-
let settled = false;
127-
128-
const rejectWithError = (error: unknown): void => {
129-
if (settled) {
130-
return;
131-
}
132-
133-
settled = true;
134-
void scope.close().then(() => {
135-
reject(error instanceof Error ? error : new Error(String(error)));
136-
}, reject);
137-
};
138-
139-
const resolveWithValue = (value: T): void => {
140-
if (settled) {
141-
return;
142-
}
143-
144-
settled = true;
145-
void scope.close().then(() => {
146-
resolve(value);
147-
}, reject);
148-
};
149-
150-
if (timeoutMs !== undefined) {
151-
invariant(
152-
timeoutResult !== undefined,
153-
'timeoutResult must be provided when timeoutMs is set',
154-
);
155-
const timeoutHandle = setTimeout(() => {
156-
resolveWithValue(timeoutResult());
157-
}, timeoutMs);
158-
scope.add(`${operationName} timeout`, () => {
159-
clearTimeout(timeoutHandle);
160-
});
161-
}
162-
163-
addAbortListener(scope, `${operationName} abort listener`, signal, () => {
164-
rejectWithError(makeAbortError(signal));
165-
});
166-
167-
void operation.then(resolveWithValue, rejectWithError);
168-
169-
return await promise;
170-
}
171-
172110
function resolveHostRendererName(input: string | undefined): RendererName {
173111
try {
174112
return resolveRendererName(

src/host/rpcClient.ts

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '../protocol/messages.js';
1616
import {
1717
addAbortListener,
18+
createResourceScopedSettlers,
1819
makeAbortError,
1920
throwIfAborted,
2021
} from '../util/abort.js';
@@ -128,19 +129,12 @@ export async function sendRpc(
128129
scope.add('rpc client socket', () => {
129130
socket.destroy();
130131
});
131-
let settled = false;
132+
const settlers = createResourceScopedSettlers(scope, resolve, reject);
132133
let responseHandled = false;
133134
let buffer = '';
134135

135136
const rejectWithCliError = (error: CliError): void => {
136-
if (settled) {
137-
return;
138-
}
139-
140-
settled = true;
141-
void scope.close().then(() => {
142-
reject(error);
143-
}, reject);
137+
settlers.reject(error);
144138
};
145139

146140
const rejectWithTransportError = (error: unknown): void => {
@@ -150,26 +144,12 @@ export async function sendRpc(
150144
};
151145

152146
const resolveWithResult = (result: unknown): void => {
153-
if (settled) {
154-
return;
155-
}
156-
157-
settled = true;
158-
void scope.close().then(() => {
159-
resolve(result);
160-
}, reject);
147+
settlers.resolve(result);
161148
};
162149

163150
if (signal !== undefined) {
164151
addAbortListener(scope, 'rpc client abort listener', signal, () => {
165-
if (settled) {
166-
return;
167-
}
168-
169-
settled = true;
170-
void scope.close().then(() => {
171-
reject(makeAbortError(signal));
172-
}, reject);
152+
settlers.reject(makeAbortError(signal));
173153
});
174154
}
175155

@@ -306,7 +286,7 @@ export async function sendRpc(
306286
});
307287

308288
socket.on('end', () => {
309-
if (settled || responseHandled) {
289+
if (settlers.isSettled() || responseHandled) {
310290
return;
311291
}
312292

src/host/rpcServer.ts

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
type RpcMethod,
1111
type RpcResponse,
1212
} from '../protocol/messages.js';
13-
import { makeAbortError } from '../util/abort.js';
13+
import { createResourceScopedSettlers, makeAbortError } from '../util/abort.js';
1414
import { invariant } from '../util/assert.js';
1515
import { ResourceScope } from '../util/resourceScope.js';
1616

@@ -58,51 +58,31 @@ async function probeSocketLiveness(socketPath: string): Promise<boolean> {
5858
return await new Promise<boolean>((resolve, reject) => {
5959
const scope = new ResourceScope();
6060
const probe = net.connect({ path: socketPath });
61-
let settled = false;
62-
63-
const resolveWithValue = (value: boolean): void => {
64-
if (settled) {
65-
return;
66-
}
67-
68-
settled = true;
69-
void scope.close().then(() => {
70-
resolve(value);
71-
}, reject);
72-
};
73-
74-
const rejectWithError = (error: unknown): void => {
75-
if (settled) {
76-
return;
77-
}
78-
79-
settled = true;
80-
void scope.close().then(() => {
81-
reject(error instanceof Error ? error : new Error(String(error)));
82-
}, reject);
83-
};
61+
const settlers = createResourceScopedSettlers(scope, resolve, reject);
8462

8563
scope.add('rpc liveness probe socket', () => {
8664
probe.destroy();
8765
});
8866
const timeoutHandle = setTimeout(() => {
89-
rejectWithError(new Error(`Timed out probing RPC socket: ${socketPath}`));
67+
// If connect neither succeeds nor fails promptly, treat the socket path as
68+
// stale rather than blocking host startup indefinitely.
69+
settlers.resolve(false);
9070
}, SOCKET_LIVENESS_PROBE_TIMEOUT_MS);
9171
scope.add('rpc liveness probe timeout', () => {
9272
clearTimeout(timeoutHandle);
9373
});
9474

9575
probe.once('connect', () => {
96-
resolveWithValue(true);
76+
settlers.resolve(true);
9777
});
9878

9979
probe.once('error', (error: NodeJS.ErrnoException) => {
10080
if (error.code === 'ECONNREFUSED' || error.code === 'ENOENT') {
101-
resolveWithValue(false);
81+
settlers.resolve(false);
10282
return;
10383
}
10484

105-
rejectWithError(error);
85+
settlers.reject(error);
10686
});
10787
});
10888
}

src/host/runCompletionCoordinator.ts

Lines changed: 16 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
RunCompletionSentinelScanner,
99
type SentinelPiece,
1010
} from './runCompletionSentinel.js';
11-
import { addAbortListener, makeAbortError } from '../util/abort.js';
11+
import { waitForScopedOperation } from '../util/abort.js';
1212
import { invariant } from '../util/assert.js';
1313
import { ResourceScope } from '../util/resourceScope.js';
1414

@@ -280,62 +280,25 @@ export class RunCompletionCoordinator {
280280
'timeoutMs must be a positive integer',
281281
);
282282

283-
const { signal } = options;
284-
if (signal?.aborted === true) {
283+
const forgetWaiter = (): void => {
284+
// Match timeout behavior: stop waiting for a client response but keep
285+
// sentinel/postamble registrations active so eventual completion bytes
286+
// remain hidden and replayable.
285287
this.#runCompletionWaiters.delete(marker);
286-
throw makeAbortError(signal);
287-
}
288-
289-
const scope = new ResourceScope();
290-
const { promise, reject, resolve } =
291-
Promise.withResolvers<TimedRunCompletionWaitResult>();
292-
let resolved = false;
293-
294-
const rejectWithError = (error: unknown): void => {
295-
if (resolved) {
296-
return;
297-
}
298-
299-
resolved = true;
300-
void scope.close().then(() => {
301-
reject(error instanceof Error ? error : new Error(String(error)));
302-
}, reject);
303-
};
304-
305-
const resolveWithResult = (result: TimedRunCompletionWaitResult): void => {
306-
if (resolved) {
307-
return;
308-
}
309-
310-
resolved = true;
311-
void scope.close().then(() => {
312-
resolve(result);
313-
}, reject);
314288
};
315289

316-
const timeoutHandle = setTimeout(() => {
317-
// Keep sentinel/postamble registrations active after timeout so the
318-
// eventual internal completion bytes are still hidden from artifacts.
319-
this.#runCompletionWaiters.delete(marker);
320-
resolveWithResult({ kind: 'timeout' });
321-
}, timeoutMs);
322-
scope.add('run completion timeout', () => {
323-
clearTimeout(timeoutHandle);
290+
return await waitForScopedOperation<TimedRunCompletionWaitResult>({
291+
operationName: 'run completion',
292+
operation: completionPromise,
293+
scope: new ResourceScope(),
294+
...(options.signal === undefined ? {} : { signal: options.signal }),
295+
timeoutMs,
296+
timeoutResult: () => {
297+
forgetWaiter();
298+
return { kind: 'timeout' };
299+
},
300+
onAbort: forgetWaiter,
324301
});
325-
326-
if (signal !== undefined) {
327-
addAbortListener(scope, 'run completion abort listener', signal, () => {
328-
// Match timeout behavior: stop waiting for a client response but keep
329-
// sentinel/postamble registrations active so eventual completion bytes
330-
// remain hidden and replayable.
331-
this.#runCompletionWaiters.delete(marker);
332-
rejectWithError(makeAbortError(signal));
333-
});
334-
}
335-
336-
void completionPromise.then(resolveWithResult, rejectWithError);
337-
338-
return await promise;
339302
}
340303

341304
async #appendOutput(data: string): Promise<void> {

0 commit comments

Comments
 (0)