fix: handle @irc/client connect() returning null + reconnect on disconnect #10

Merged
alex merged 1 commit from pi/issue7-1784658236137 into master 2026-07-21 18:28:06 +00:00
Contributor

Root cause

The previous retry logic (retryForever) only caught thrown errors. But @irc/client's Client.connect() catches connection errors internally and returns null instead of throwing:

// @irc/client core/client.ts — connect()
try {
    this.conn = await this.runtime.connectTls(...);
} catch (error) {
    this.emitError("connect", error);  // emits "error" event
    return null;                        // returns null, does NOT throw
}

So when the IRC server was unreachable:

  1. connect() resolved with null (the error was only emitted as an event)
  2. retryForever saw a resolved promise → treated it as success → returned
  3. The bot logged "Connected and greeting newcomers" with a dead connection
  4. No active I/O kept the event loop alive → process exited
  5. systemd restarted it → same crash → start-limit-hit

This also explains the confusing log output where "Connected" appeared before the error.

Fix

Three changes in src/main.ts:

1. Check connect() return value for null

const conn = await client.connect(config.server, { port, tls });
if (conn === null) {
    throw new Error(`Connection to ${config.server}:${config.port} was refused`);
}

This converts the silent null return into a thrown error so retryForever actually retries.

2. Wrap the entire connection lifecycle in retryForever

Previously, retryForever only covered the initial connect() call. Now the operation also includes await client.once("disconnected") — it blocks until the connection drops, then throws "Disconnected from server" to trigger a reconnection with backoff. This handles the "IRC server upgrade" scenario where an established connection is lost.

3. Restructured SIGINT handler

The shutdown handler now uses a mutable currentClient reference (via closure) instead of receiving a fixed Client, so it can always reach the live connection across reconnections. The shuttingDown flag doubles as a re-entrancy guard and a signal for the retry loop to stop.

Test added

A new test documents the @irc/client quirk — an operation that resolves with null (simulating connect() failure) must be detected and converted to a thrown error for retryForever to engage.

Fixes #7

## Root cause The previous retry logic (`retryForever`) only caught **thrown** errors. But `@irc/client`'s `Client.connect()` **catches connection errors internally and returns `null`** instead of throwing: ```typescript // @irc/client core/client.ts — connect() try { this.conn = await this.runtime.connectTls(...); } catch (error) { this.emitError("connect", error); // emits "error" event return null; // returns null, does NOT throw } ``` So when the IRC server was unreachable: 1. `connect()` resolved with `null` (the error was only emitted as an event) 2. `retryForever` saw a resolved promise → treated it as success → returned 3. The bot logged "Connected and greeting newcomers" with a dead connection 4. No active I/O kept the event loop alive → process exited 5. systemd restarted it → same crash → `start-limit-hit` This also explains the confusing log output where "Connected" appeared *before* the error. ## Fix Three changes in `src/main.ts`: ### 1. Check `connect()` return value for `null` ```typescript const conn = await client.connect(config.server, { port, tls }); if (conn === null) { throw new Error(`Connection to ${config.server}:${config.port} was refused`); } ``` This converts the silent `null` return into a thrown error so `retryForever` actually retries. ### 2. Wrap the entire connection lifecycle in `retryForever` Previously, `retryForever` only covered the initial `connect()` call. Now the operation also includes `await client.once("disconnected")` — it blocks until the connection drops, then throws `"Disconnected from server"` to trigger a reconnection with backoff. This handles the "IRC server upgrade" scenario where an established connection is lost. ### 3. Restructured SIGINT handler The shutdown handler now uses a mutable `currentClient` reference (via closure) instead of receiving a fixed `Client`, so it can always reach the live connection across reconnections. The `shuttingDown` flag doubles as a re-entrancy guard and a signal for the retry loop to stop. ## Test added A new test documents the `@irc/client` quirk — an operation that resolves with `null` (simulating `connect()` failure) must be detected and converted to a thrown error for `retryForever` to engage. Fixes #7
fix: handle @irc/client connect() returning null + reconnect on disconnect
All checks were successful
PR / deno (pull_request) Successful in 20s
PR / fallow (pull_request) Successful in 28s
Build and Push Container Image / build-and-push-1 (push) Successful in 1m30s
Build and Push Container Image / build-and-push (push) Successful in 0s
Build and Push Container Image / deploy-1 (push) Successful in 5s
Build and Push Container Image / deploy (push) Successful in 0s
97c2385ab8
Co-authored-by: alex <alex@forge.l3x.in>
alex merged commit 97c2385ab8 into master 2026-07-21 18:28:06 +00:00
alex deleted branch pi/issue7-1784658236137 2026-07-21 18:28:06 +00:00
Sign in to join this conversation.
No reviewers
No labels
dependencies
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
alex/irc-greeter-bot!10
No description provided.