fix(discord): stop cleanly during reconnect drain

This commit is contained in:
Nimrod Gutman
2026-03-26 10:27:30 +02:00
parent 391db13c4c
commit d2fa2294d7
2 changed files with 64 additions and 8 deletions

View File

@@ -479,6 +479,41 @@ describe("runDiscordGatewayLifecycle", () => {
}
});
it("treats drain timeout as a graceful stop after lifecycle abort", async () => {
vi.useFakeTimers();
try {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const socket = new EventEmitter();
const { emitter, gateway } = createGatewayHarness({ ws: socket });
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
const abortController = new AbortController();
const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } =
createLifecycleHarness({ gateway });
lifecycleParams.abortSignal = abortController.signal;
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
await vi.advanceTimersByTimeAsync(15_100);
abortController.abort();
await vi.advanceTimersByTimeAsync(5_500);
await expect(lifecyclePromise).resolves.toBeUndefined();
expect(gateway.connect).not.toHaveBeenCalled();
expect(runtimeError).not.toHaveBeenCalledWith(
expect.stringContaining("gateway socket did not close within 5000ms before reconnect"),
);
expectLifecycleCleanup({
start,
stop,
threadStop,
waitCalls: 0,
gatewaySupervisor,
});
} finally {
vi.useRealTimers();
}
});
it("fails fast when startup never reaches READY after a forced reconnect", async () => {
vi.useFakeTimers();
try {

View File

@@ -219,7 +219,8 @@ export async function runDiscordGatewayLifecycle(params: {
let drainTimeout: ReturnType<typeof setTimeout> | undefined;
let terminateCloseTimeout: ReturnType<typeof setTimeout> | undefined;
const ignoreSocketError = () => {};
const cleanup = () => {
const shouldStopWaiting = () => lifecycleStopping || params.abortSignal?.aborted;
const clearPendingTimers = () => {
if (drainTimeout) {
clearTimeout(drainTimeout);
drainTimeout = undefined;
@@ -228,6 +229,9 @@ export async function runDiscordGatewayLifecycle(params: {
clearTimeout(terminateCloseTimeout);
terminateCloseTimeout = undefined;
}
};
const cleanup = () => {
clearPendingTimers();
socket.removeListener("close", onClose);
socket.removeListener("error", ignoreSocketError);
};
@@ -239,19 +243,28 @@ export async function runDiscordGatewayLifecycle(params: {
settled = true;
resolve();
};
const rejectClose = (error: Error) => {
const resolveStoppedWait = () => {
if (settled) {
return;
}
settled = true;
if (drainTimeout) {
clearTimeout(drainTimeout);
drainTimeout = undefined;
clearPendingTimers();
// Keep suppressing late ws errors until the socket actually closes.
// The original Carbon listeners were removed above, and `terminate()`
// can still asynchronously emit "error" before "close".
resolve();
};
const rejectClose = (error: Error) => {
if (shouldStopWaiting()) {
resolveStoppedWait();
return;
}
if (terminateCloseTimeout) {
clearTimeout(terminateCloseTimeout);
terminateCloseTimeout = undefined;
if (settled) {
return;
}
settled = true;
clearPendingTimers();
// Keep suppressing late ws errors until the socket actually closes.
// The original Carbon listeners were removed above, and `terminate()`
@@ -263,6 +276,10 @@ export async function runDiscordGatewayLifecycle(params: {
if (settled) {
return;
}
if (shouldStopWaiting()) {
resolveStoppedWait();
return;
}
params.runtime.error?.(
danger(
`discord: gateway socket did not close within ${DISCORD_GATEWAY_DISCONNECT_DRAIN_TIMEOUT_MS}ms before reconnect; attempting forced terminate before giving up`,
@@ -298,6 +315,10 @@ export async function runDiscordGatewayLifecycle(params: {
if (settled) {
return;
}
if (shouldStopWaiting()) {
resolveStoppedWait();
return;
}
params.runtime.error?.(
danger(
`discord: gateway socket did not close ${DISCORD_GATEWAY_FORCE_TERMINATE_CLOSE_TIMEOUT_MS}ms after forced terminate; force-stopping instead of opening a parallel socket`,