Skip to content

fix(discord): catch unhandled promise rejection in gateway registerClient (#34592)#35145

Closed
lml2468 wants to merge 2 commits into
openclaw:mainfrom
lml2468:fix/issue-34592-discord-gateway-crash
Closed

fix(discord): catch unhandled promise rejection in gateway registerClient (#34592)#35145
lml2468 wants to merge 2 commits into
openclaw:mainfrom
lml2468:fix/issue-34592-discord-gateway-crash

Conversation

@lml2468

@lml2468 lml2468 commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

What

Catch unhandled promise rejections in GatewayPlugin.registerClient() that crash the entire gateway process when the Discord API is unreachable.

Why

Fixes #34592 — When Discord API is unreachable (e.g., blocked regions without proxy/TUN), registerClient throws during the gateway info fetch. Carbon's Client constructor calls plugin.registerClient(this) without awaiting the returned promise, so any rejection becomes an unhandled promise rejection that kills the Node.js process. This causes the gateway to crash and restart every ~3 seconds in an infinite loop, taking down all channels (Telegram, etc.) along with it.

How

  • SafeGatewayPlugin: New subclass wrapping super.registerClient() in try/catch for both proxy and non-proxy code paths. Failures are logged as errors instead of crashing the process.
  • FALLBACK_GATEWAY_INFO: When proxy fetch fails, provide sensible defaults (url, shards, session_start_limit) so Carbon's reconnect logic can still attempt WebSocket connections with exponential backoff.
  • Invalid proxy fallback: Also returns SafeGatewayPlugin instead of bare GatewayPlugin to maintain the crash guard.

Root cause in Carbon

// Carbon Client constructor (line 122)
for (const plugin of plugins) {
    plugin.registerClient?.(this);  // async, NOT awaited!
}

This is an upstream issue in @buape/carbon, but the fix is applied on the OpenClaw side to avoid depending on an upstream release.

Testing

  • pnpm build passes
  • pnpm check passes (format + lint + typecheck)
  • 6 new tests covering all crash guard scenarios:
    • No proxy: registerClient rejection caught ✅
    • No proxy: normal operation unaffected ✅
    • Proxy: fetch failure → fallback gateway info ✅
    • Proxy: both fetch + registerClient failure caught ✅
    • Proxy: normal operation unaffected ✅
    • Invalid proxy: fallback SafeGatewayPlugin catches errors ✅
  • 3 existing provider.proxy.test.ts tests still pass (one assertion updated: toBe(GatewayPlugin.prototype)toBeInstanceOf(GatewayPlugin))
  • AI-assisted (OpenClaw agent, fully tested)

Files changed

File Change
src/discord/monitor/gateway-plugin.ts SafeGatewayPlugin + FALLBACK_GATEWAY_INFO + logRegisterClientFailure
src/discord/monitor/gateway-plugin.crash-guard.test.ts 6 new test cases
src/discord/monitor/provider.proxy.test.ts Updated assertion for invalid proxy fallback

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

greptile-apps Bot commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a process-crashing unhandled promise rejection in the Discord gateway by wrapping GatewayPlugin.registerClient() in try/catch guards across all three instantiation paths (no-proxy, proxy, and invalid-proxy fallback). It also introduces a FALLBACK_GATEWAY_INFO constant so the proxy path can hand off sensible defaults to Carbon's WebSocket reconnect logic when the Discord API is unreachable.

Key changes:

  • SafeGatewayPlugin subclass wraps super.registerClient() in a try/catch and logs errors via logRegisterClientFailure() instead of letting the rejection propagate unhandled — applied to both the no-proxy and invalid-proxy-fallback paths.
  • ProxyGatewayPlugin.registerClient() now catches fetch failures and falls back to FALLBACK_GATEWAY_INFO (standard Discord gateway URL + reasonable session_start_limit defaults) rather than re-throwing, then also guards super.registerClient() with the same try/catch pattern.
  • The provider.proxy.test.ts assertion is updated from strict prototype equality to toBeInstanceOf, correctly reflecting that the returned value is now a subclass.
  • 6 new test cases cover every crash-guard scenario.

Minor concern: SafeGatewayPlugin is defined with identical logic in two separate branches of createDiscordGatewayPlugin. Extracting it into a shared inner factory would reduce the duplication and maintenance risk.

Confidence Score: 4/5

  • This PR is safe to merge — it correctly prevents a process-crashing unhandled rejection with no functional regressions.
  • The fix is targeted, well-tested (6 new crash-guard tests plus 3 existing tests still passing), and the approach is sound. The only concern is the duplicated SafeGatewayPlugin class definition, which is a maintainability issue rather than a correctness bug.
  • No files require special attention beyond the style nit in gateway-plugin.ts regarding the duplicate class definition.

Last reviewed commit: 8c7fd39

Comment thread src/discord/monitor/gateway-plugin.ts Outdated
Comment on lines +76 to +161
@@ -82,6 +140,24 @@ export function createDiscordGatewayPlugin(params: {
return new ProxyGatewayPlugin();
} catch (err) {
params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`));
return new GatewayPlugin(options);

// Fall back to a safe non-proxy plugin.
class SafeGatewayPlugin extends GatewayPlugin {
constructor() {
super(options);
}

override async registerClient(
client: Parameters<GatewayPlugin["registerClient"]>[0],
): Promise<void> {
try {
await super.registerClient(client);
} catch (error) {
logRegisterClientFailure(error, params.runtime);
}
}
}

return new SafeGatewayPlugin();

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.

Duplicated SafeGatewayPlugin class definition

SafeGatewayPlugin is defined with identical logic in two separate branches: the !proxy path (lines 76–90) and the invalid-proxy catch fallback (lines 145–159). If this behaviour ever needs to change, both copies must be updated in sync.

Consider extracting a single shared factory before the if (!proxy) block:

function makeSafePlugin() {
  class SafeGatewayPlugin extends GatewayPlugin {
    constructor() {
      super(options);
    }
    override async registerClient(
      client: Parameters<GatewayPlugin["registerClient"]>[0],
    ): Promise<void> {
      try {
        await super.registerClient(client);
      } catch (error) {
        logRegisterClientFailure(error, params.runtime);
      }
    }
  }
  return new SafeGatewayPlugin();
}

Then both branches simply call return makeSafePlugin();.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/discord/monitor/gateway-plugin.ts
Line: 76-161

Comment:
**Duplicated `SafeGatewayPlugin` class definition**

`SafeGatewayPlugin` is defined with identical logic in two separate branches: the `!proxy` path (lines 76–90) and the invalid-proxy `catch` fallback (lines 145–159). If this behaviour ever needs to change, both copies must be updated in sync.

Consider extracting a single shared factory before the `if (!proxy)` block:

```ts
function makeSafePlugin() {
  class SafeGatewayPlugin extends GatewayPlugin {
    constructor() {
      super(options);
    }
    override async registerClient(
      client: Parameters<GatewayPlugin["registerClient"]>[0],
    ): Promise<void> {
      try {
        await super.registerClient(client);
      } catch (error) {
        logRegisterClientFailure(error, params.runtime);
      }
    }
  }
  return new SafeGatewayPlugin();
}
```

Then both branches simply call `return makeSafePlugin();`.

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

Comment on lines +141 to +150
beforeEach(() => {
baseRegisterClientSpy.mockClear();
baseRegisterClientSpy.mockReset();
undiciFetchMock.mockClear();
undiciFetchMock.mockReset();
undiciProxyAgentSpy.mockClear();
wsProxyAgentSpy.mockClear();
webSocketSpy.mockClear();
resetLastAgent();
});

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.

Redundant mockClear() before mockReset()

mockReset() is a superset of mockClear() — it clears call history and resets the implementation. Calling mockClear() immediately before mockReset() on the same spy is a no-op and just adds noise.

Suggested change
beforeEach(() => {
baseRegisterClientSpy.mockClear();
baseRegisterClientSpy.mockReset();
undiciFetchMock.mockClear();
undiciFetchMock.mockReset();
undiciProxyAgentSpy.mockClear();
wsProxyAgentSpy.mockClear();
webSocketSpy.mockClear();
resetLastAgent();
});
beforeEach(() => {
baseRegisterClientSpy.mockReset();
undiciFetchMock.mockReset();
undiciProxyAgentSpy.mockClear();
wsProxyAgentSpy.mockClear();
webSocketSpy.mockClear();
resetLastAgent();
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/discord/monitor/gateway-plugin.crash-guard.test.ts
Line: 141-150

Comment:
**Redundant `mockClear()` before `mockReset()`**

`mockReset()` is a superset of `mockClear()` — it clears call history *and* resets the implementation. Calling `mockClear()` immediately before `mockReset()` on the same spy is a no-op and just adds noise.

```suggestion
  beforeEach(() => {
    baseRegisterClientSpy.mockReset();
    undiciFetchMock.mockReset();
    undiciProxyAgentSpy.mockClear();
    wsProxyAgentSpy.mockClear();
    webSocketSpy.mockClear();
    resetLastAgent();
  });
```

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

lml2468 added 2 commits March 12, 2026 16:34
…ient (openclaw#34592)

Carbon's Client constructor calls plugin.registerClient() without awaiting
the returned promise. When Discord API is unreachable (e.g., blocked regions
without proxy), the fetch failure becomes an unhandled promise rejection that
crashes the entire gateway process in an infinite restart loop.

Changes:
- SafeGatewayPlugin: wrap super.registerClient() in try/catch for both
  proxy and non-proxy code paths
- ProxyGatewayPlugin: catch proxy fetch failure gracefully, fall back to
  default gateway URL instead of throwing
- FALLBACK_GATEWAY_INFO: provide sensible defaults (url, shards,
  session_start_limit) so Carbon's reconnect logic can still attempt
  WebSocket connections with exponential backoff
- Invalid proxy fallback also returns SafeGatewayPlugin instead of bare
  GatewayPlugin

AI-assisted (OpenClaw agent, fully tested)
…GatewayPlugin class

DRY: the identical SafeGatewayPlugin class was defined twice (no-proxy path
and invalid-proxy fallback). Extract into a shared factory function
createSafeGatewayPlugin(options, runtime) that both paths now call.
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: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Discord channel connection failure causes gateway infinite restart loop

1 participant