fix(gateway): clear stale Slack socket state after disconnect (#39083)

* fix(gateway): restore stale-socket recovery

* test(slack): cover clean socket disconnect status
This commit is contained in:
Tak Hoffman
2026-03-07 12:37:32 -06:00
committed by GitHub
parent fbb9bb08c5
commit 52e7d4295e
4 changed files with 77 additions and 3 deletions

View File

@@ -174,7 +174,7 @@ describe("evaluateChannelHealth", () => {
},
{
channelId: "slack",
now: 100_000,
now: 75_000,
channelConnectGraceMs: 10_000,
staleEventThresholdMs: 30_000,
},
@@ -194,13 +194,33 @@ describe("evaluateChannelHealth", () => {
},
{
channelId: "slack",
now: 100_000,
now: 75_000,
channelConnectGraceMs: 10_000,
staleEventThresholdMs: 30_000,
},
);
expect(evaluation).toEqual({ healthy: true, reason: "healthy" });
});
it("flags inherited event timestamps after the lifecycle exceeds the stale threshold", () => {
const evaluation = evaluateChannelHealth(
{
running: true,
connected: true,
enabled: true,
configured: true,
lastStartAt: 50_000,
lastEventAt: 10_000,
},
{
channelId: "slack",
now: 140_000,
channelConnectGraceMs: 10_000,
staleEventThresholdMs: 30_000,
},
);
expect(evaluation).toEqual({ healthy: false, reason: "stale-socket" });
});
});
describe("resolveChannelRestartReason", () => {

View File

@@ -109,7 +109,11 @@ export function evaluateChannelHealth(
snapshot.lastEventAt != null
) {
if (lastStartAt != null && snapshot.lastEventAt < lastStartAt) {
return { healthy: true, reason: "healthy" };
const lifecycleEventGap = Math.max(0, policy.now - lastStartAt);
if (lifecycleEventGap <= policy.staleEventThresholdMs) {
return { healthy: true, reason: "healthy" };
}
return { healthy: false, reason: "stale-socket" };
}
const eventAge = policy.now - snapshot.lastEventAt;
if (eventAge > policy.staleEventThresholdMs) {

View File

@@ -38,6 +38,38 @@ describe("slack socket reconnect helpers", () => {
);
});
it("clears connected state when socket mode disconnects", () => {
const setStatus = vi.fn();
const err = new Error("dns down");
__testing.publishSlackDisconnectedStatus(setStatus, err);
expect(setStatus).toHaveBeenCalledTimes(1);
expect(setStatus).toHaveBeenCalledWith({
connected: false,
lastDisconnect: {
at: expect.any(Number),
error: "dns down",
},
lastError: "dns down",
});
});
it("clears connected state without error when socket mode disconnects cleanly", () => {
const setStatus = vi.fn();
__testing.publishSlackDisconnectedStatus(setStatus);
expect(setStatus).toHaveBeenCalledTimes(1);
expect(setStatus).toHaveBeenCalledWith({
connected: false,
lastDisconnect: {
at: expect.any(Number),
},
lastError: null,
});
});
it("resolves disconnect waiter on socket disconnect event", async () => {
const client = new FakeEmitter();
const app = { receiver: { client } };

View File

@@ -77,6 +77,22 @@ function publishSlackConnectedStatus(setStatus?: (next: Record<string, unknown>)
});
}
function publishSlackDisconnectedStatus(
setStatus?: (next: Record<string, unknown>) => void,
error?: unknown,
) {
if (!setStatus) {
return;
}
const at = Date.now();
const message = error ? formatUnknownError(error) : undefined;
setStatus({
connected: false,
lastDisconnect: message ? { at, error: message } : { at },
lastError: message ?? null,
});
}
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const cfg = opts.config ?? loadConfig();
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
@@ -440,6 +456,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
if (opts.abortSignal?.aborted) {
break;
}
publishSlackDisconnectedStatus(opts.setStatus, disconnect.error);
// Bail immediately on non-recoverable auth errors during reconnect too.
if (disconnect.error && isNonRecoverableSlackAuthError(disconnect.error)) {
@@ -495,6 +512,7 @@ export { isNonRecoverableSlackAuthError } from "./reconnect-policy.js";
export const __testing = {
publishSlackConnectedStatus,
publishSlackDisconnectedStatus,
resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
getSocketEmitter,