fix(slack): pre-set shuttingDown before app.stop() to prevent orphaned ping intervals (#56646)

Merged via squash with admin override.

Prepared head SHA: f1c91d50b0
Note: required red lanes are currently inherited from latest origin/main, not introduced by this PR.
Co-authored-by: hsiaoa <70124331+hsiaoa@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
This commit is contained in:
Hsiao A
2026-04-04 20:49:23 +08:00
committed by GitHub
parent 16346d6784
commit ae16452a69
3 changed files with 55 additions and 4 deletions

View File

@@ -611,6 +611,7 @@ Docs: https://docs.openclaw.ai
- Telegram/forum topics: keep native `/new` and `/reset` routed to the active topic by preserving the topic target on forum-thread command context. (#35963)
- Status/port diagnostics: treat single-process dual-stack loopback gateway listeners as healthy in `openclaw status --all`, suppressing false "port already in use" conflict warnings. (#53398) Thanks @DanWebb1949.
- CLI/Docker: treat loopback private-host CLI gateway connects as local for silent pairing auto-approval, while keeping remote backend and public-host CLI connects behind pairing. (#55113) Thanks @sar618.
- Slack/socket mode: mark the underlying socket client as shutting down before provider stop paths call Bolt teardown so stale-socket restarts stop leaking orphaned ping reconnect loops. (#56646) Thanks @hsiaoa.
## 2026.3.24

View File

@@ -104,4 +104,18 @@ describe("slack socket reconnect helpers", () => {
error: err,
});
});
it("marks the socket client as shutting down before stop runs", async () => {
const app = {
receiver: { client: { shuttingDown: false } },
stop: vi.fn().mockImplementation(async () => {
expect(app.receiver.client.shuttingDown).toBe(true);
}),
};
await __testing.gracefulStopSlackApp(app);
expect(app.stop).toHaveBeenCalledTimes(1);
expect(app.receiver.client.shuttingDown).toBe(true);
});
});

View File

@@ -59,6 +59,9 @@ type SlackBoltResolvedExports = {
App: SlackAppConstructor;
HTTPReceiver: SlackHttpReceiverConstructor;
};
type SlackSocketShutdownClient = {
shuttingDown?: boolean;
};
type Constructor = abstract new (...args: never[]) => unknown;
function isConstructorFunction<T extends Constructor>(value: unknown): value is T {
@@ -171,6 +174,29 @@ function publishSlackDisconnectedStatus(
});
}
function resolveSlackSocketShutdownClient(app: unknown): SlackSocketShutdownClient | undefined {
if (!app || typeof app !== "object") {
return undefined;
}
const receiver = Reflect.get(app, "receiver");
if (!receiver || typeof receiver !== "object") {
return undefined;
}
const client = Reflect.get(receiver, "client");
if (!client || typeof client !== "object") {
return undefined;
}
return client as SlackSocketShutdownClient;
}
async function gracefulStopSlackApp(app: { stop: () => unknown }) {
const socketClient = resolveSlackSocketShutdownClient(app);
if (socketClient) {
socketClient.shuttingDown = true;
}
await Promise.resolve(app.stop()).catch(() => undefined);
}
function formatSlackResolvedLabel(params: {
input: string;
id: string;
@@ -202,7 +228,6 @@ function formatSlackUserResolved(entry: SlackUserResolution): string {
extra: entry.note ? [entry.note] : [],
});
}
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const cfg = opts.config ?? loadConfig();
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
@@ -319,6 +344,15 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
clientOptions,
},
);
// Pre-set shuttingDown on the SocketModeClient before app.stop() to prevent
// a race where the library's internal ping timeout fires disconnect() before
// shuttingDown is set, causing orphaned reconnects with leaked ping intervals.
// See: openclaw/openclaw#56508
const gracefulStop = async () => {
await gracefulStopSlackApp(app);
};
const slackHttpHandler =
slackMode === "http" && receiver
? async (req: IncomingMessage, res: ServerResponse) => {
@@ -529,7 +563,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const stopOnAbort = () => {
if (opts.abortSignal?.aborted && slackMode === "socket") {
void app.stop();
void gracefulStop();
}
};
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
@@ -607,7 +641,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
disconnect.error ? ` (${formatUnknownError(disconnect.error)})` : ""
}`,
);
await app.stop().catch(() => undefined);
await gracefulStop();
try {
await sleepWithAbort(delayMs, opts.abortSignal);
} catch {
@@ -628,7 +662,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
unregisterHttpHandler?.();
await execApprovalsHandler?.stop().catch(() => undefined);
await app.stop().catch(() => undefined);
await gracefulStop();
}
}
@@ -641,6 +675,8 @@ export const __testing = {
formatSlackUserResolved,
publishSlackConnectedStatus,
publishSlackDisconnectedStatus,
resolveSlackSocketShutdownClient,
gracefulStopSlackApp,
resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveSlackBoltInterop,