Skip to content

fix(discord): add stopAccount to close stale WS on health-monitor restart#51297

Closed
ajitpratap0 wants to merge 2 commits into
openclaw:mainfrom
ajitpratap0:fix/50913-discord-stop-account
Closed

fix(discord): add stopAccount to close stale WS on health-monitor restart#51297
ajitpratap0 wants to merge 2 commits into
openclaw:mainfrom
ajitpratap0:fix/50913-discord-stop-account

Conversation

@ajitpratap0

Copy link
Copy Markdown

Summary

The Discord channel plugin's gateway object was missing a stopAccount() implementation. When the health-monitor triggered a hot restart, the old WebSocket (GatewayPlugin) remained connected, causing stale connections to linger and potentially process duplicate events.

Changes

  • Added stopAccount() to the gateway object in extensions/discord/src/channel.ts
  • On stop: retrieves the active GatewayPlugin via getGateway(accountId), sets reconnect.maxAttempts = 0 to prevent reconnection, calls gateway.disconnect() to close the WebSocket, then calls unregisterGateway(accountId) to clean up the registry

Testing

  • Existing tests cover registerGateway/unregisterGateway lifecycle
  • The stopAccount path mirrors the existing onAbort handler in provider.lifecycle.ts which already uses this pattern

Related

Fixes #50913

@openclaw-barnacle openclaw-barnacle Bot added channel: discord Channel integration: discord size: XS labels Mar 20, 2026
@greptile-apps

greptile-apps Bot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a stopAccount() implementation to the Discord channel plugin's gateway object, closing the stale WebSocket (GatewayPlugin) when the health-monitor triggers a hot restart instead of leaving it connected and potentially processing duplicate events.

Key changes:

  • stopAccount retrieves the active GatewayPlugin via getGateway(accountId), sets reconnect.maxAttempts = 0 to prevent reconnection, calls gateway.disconnect() to close the WebSocket, and calls unregisterGateway(accountId) to clean up the registry.
  • The cleanup logic mirrors the existing onAbort handler in provider.lifecycle.ts.

Potential concern:

  • The onAbort handler in provider.lifecycle.ts registers gatewayEmitter?.once("error", () => {}) before calling gateway.disconnect() to silently absorb any error event emitted during teardown. stopAccount omits this step. While waitForDiscordGatewayStop's own error handler would catch such an event while the lifecycle is running, it routes errors through shouldStopOnGatewayError rather than discarding them — meaning a "Max reconnect attempts" error triggered immediately after maxAttempts is set to 0 could cause the lifecycle to exit via its error branch instead of cleanly.

Confidence Score: 4/5

  • Safe to merge with a minor improvement recommended to align error-event handling with the existing onAbort pattern.
  • The core logic — retrieving the gateway, disabling reconnection, disconnecting, and unregistering — is correct and mirrors the established onAbort pattern. The unregisterGateway call outside the try/catch ensures cleanup happens even when disconnect() throws. The only gap is the missing error-event suppressor (gatewayEmitter?.once("error", () => {})), which could allow a disconnect-triggered error event to be routed through waitForDiscordGatewayStop's non-silent handler rather than being silently swallowed, but this is unlikely to cause a visible regression in practice.
  • No files require special attention beyond the one noted comment in extensions/discord/src/channel.ts.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/discord/src/channel.ts
Line: 701

Comment:
**Missing error-event suppressor before `disconnect()`**

The analogous `onAbort` handler in `provider.lifecycle.ts` installs a one-time error listener on `gatewayEmitter` _before_ calling `gateway.disconnect()`:

```ts
gatewayEmitter?.once("error", () => {});
gateway.options.reconnect = { maxAttempts: 0 };
gateway.disconnect();
```

This is a deliberate defensive measure: disconnecting an active WebSocket can emit an `"error"` event on the emitter. When the lifecycle is still running, `waitForDiscordGatewayStop`'s own error handler will pick that event up and run it through `shouldStopOnGatewayError` — which returns `true` for `"Max reconnect attempts"`, a message that may appear immediately after `maxAttempts` is set to `0`. If that path fires, the lifecycle exits via the error branch rather than cleanly.

To fully mirror `onAbort`, consider importing `getDiscordGatewayEmitter` (already available from `../monitor.gateway.js` as used in `provider.lifecycle.ts`) and suppressing the error event before disconnecting:

```ts
import { getDiscordGatewayEmitter } from "../monitor.gateway.js";

// inside stopAccount:
const emitter = getDiscordGatewayEmitter(gateway);
emitter?.once("error", () => {});
gateway.options.reconnect = { maxAttempts: 0 };
gateway.disconnect();
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "fix(discord): add st..."

try {
// Prevent reconnect attempts and forcibly close the WebSocket
gateway.options.reconnect = { maxAttempts: 0 };
gateway.disconnect();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing error-event suppressor before disconnect()

The analogous onAbort handler in provider.lifecycle.ts installs a one-time error listener on gatewayEmitter before calling gateway.disconnect():

gatewayEmitter?.once("error", () => {});
gateway.options.reconnect = { maxAttempts: 0 };
gateway.disconnect();

This is a deliberate defensive measure: disconnecting an active WebSocket can emit an "error" event on the emitter. When the lifecycle is still running, waitForDiscordGatewayStop's own error handler will pick that event up and run it through shouldStopOnGatewayError — which returns true for "Max reconnect attempts", a message that may appear immediately after maxAttempts is set to 0. If that path fires, the lifecycle exits via the error branch rather than cleanly.

To fully mirror onAbort, consider importing getDiscordGatewayEmitter (already available from ../monitor.gateway.js as used in provider.lifecycle.ts) and suppressing the error event before disconnecting:

import { getDiscordGatewayEmitter } from "../monitor.gateway.js";

// inside stopAccount:
const emitter = getDiscordGatewayEmitter(gateway);
emitter?.once("error", () => {});
gateway.options.reconnect = { maxAttempts: 0 };
gateway.disconnect();
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/discord/src/channel.ts
Line: 701

Comment:
**Missing error-event suppressor before `disconnect()`**

The analogous `onAbort` handler in `provider.lifecycle.ts` installs a one-time error listener on `gatewayEmitter` _before_ calling `gateway.disconnect()`:

```ts
gatewayEmitter?.once("error", () => {});
gateway.options.reconnect = { maxAttempts: 0 };
gateway.disconnect();
```

This is a deliberate defensive measure: disconnecting an active WebSocket can emit an `"error"` event on the emitter. When the lifecycle is still running, `waitForDiscordGatewayStop`'s own error handler will pick that event up and run it through `shouldStopOnGatewayError` — which returns `true` for `"Max reconnect attempts"`, a message that may appear immediately after `maxAttempts` is set to `0`. If that path fires, the lifecycle exits via the error branch rather than cleanly.

To fully mirror `onAbort`, consider importing `getDiscordGatewayEmitter` (already available from `../monitor.gateway.js` as used in `provider.lifecycle.ts`) and suppressing the error event before disconnecting:

```ts
import { getDiscordGatewayEmitter } from "../monitor.gateway.js";

// inside stopAccount:
const emitter = getDiscordGatewayEmitter(gateway);
emitter?.once("error", () => {});
gateway.options.reconnect = { maxAttempts: 0 };
gateway.disconnect();
```

How can I resolve this? If you propose a fix, please make it concise.

@ajitpratap0
ajitpratap0 force-pushed the fix/50913-discord-stop-account branch 2 times, most recently from 31f50e3 to 1501d31 Compare March 20, 2026 22:29

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1501d31421

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/discord/src/channel.ts Outdated
Comment on lines +696 to +697
const accountId = ctx.accountId;
const gateway = getGateway(accountId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Look up gateway with normalized account ID

stopAccount uses ctx.accountId for getGateway, but this context field is the raw config key from server-channels while Discord gateways are registered under the normalized account.accountId (see resolveDiscordAccount + startAccount). For accounts whose key is not already canonical (e.g., mixed case or punctuation), this lookup misses, so the stale gateway is never disconnected/unregistered and hot restarts can still leave duplicate WebSocket listeners running.

Useful? React with 👍 / 👎.

Ajit Pratap Singh and others added 2 commits March 23, 2026 02:22
…tart

The Discord channel plugin's gateway object lacked a stopAccount()
implementation. When the health-monitor triggered a hot restart, the old
WebSocket/GatewayPlugin remained connected and lingered until garbage
collection, causing duplicate event processing and resource leaks.

Add stopAccount() that:
- Retrieves the registered GatewayPlugin for the account via getGateway()
- Disables reconnect (maxAttempts: 0) to prevent re-connection after disconnect
- Calls gateway.disconnect() to close the WebSocket immediately
- Unregisters the gateway from the module-level registry via unregisterGateway()

Fixes openclaw#50913
…count

- `getDiscordGatewayEmitter` + error suppressor were already present from
  the original commit; no change needed for Issue 1.
- Normalize `ctx.accountId` via `resolveDiscordAccount` before the
  `getGateway` lookup so non-canonical account keys resolve to the same
  ID under which the gateway was registered (matching `startAccount`).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@steipete

Copy link
Copy Markdown
Contributor

Closing this as not reproducible on current main after Codex review.

Current main already closes Discord gateway sockets on channel stop/restart via the generic abort path, so the stale-WS condition described by PR #51297 is not reproducible from the current code. The proposed stopAccount() hook in extensions/discord/src/channel.ts would duplicate existing shutdown behavior rather than add a missing cleanup path.

What I checked:

  • Health monitor restart uses stop/start: The health monitor restarts unhealthy channels by calling channelManager.stopChannel(...) and then startChannel(...), so Discord restart cleanup flows through the normal channel stop path. (src/gateway/channel-health-monitor.ts:160, 608c08fc54da)
  • Channel stop aborts the running gateway task and waits for shutdown: stopChannel aborts the stored task with abort?.abort() and then waits for graceful stop before continuing. This applies even when a plugin does not define gateway.stopAccount. (src/gateway/server-channels.ts:528, 608c08fc54da)
  • Discord abort path disconnects the WebSocket: waitForDiscordGatewayStop resolves abort by calling gateway?.disconnect?.() before cleanup, which is exactly the stale-socket teardown the PR is trying to add manually. (extensions/discord/src/monitor.gateway.ts:18, 608c08fc54da)
  • Discord lifecycle unregisters the gateway registry entry on shutdown: After the gateway lifecycle exits, Discord cleanup calls unregisterGateway(params.accountId) in finally, so the registry is also cleaned during the existing stop flow. (extensions/discord/src/monitor/provider.lifecycle.ts:456, 608c08fc54da)
  • Regression test covers abort-driven disconnect: monitor.gateway.test.ts explicitly asserts that aborting the Discord wait path resolves and disconnects the gateway. (extensions/discord/src/monitor.gateway.test.ts:80, 608c08fc54da)
  • Discord plugin currently has no custom stop hook: The Discord channel plugin defines gateway.startAccount only; current restart safety comes from the generic abort/lifecycle path above, not from a plugin-specific stopAccount hook. (extensions/discord/src/channel.ts:715, 608c08fc54da)

Review notes: reviewed against 608c08fc54da.

@steipete steipete closed this Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: discord Channel integration: discord size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discord channel plugin missing stopAccount — health-monitor hot restart leaves stale WS, enters death loop

2 participants