Skip to content

CloudSQLInstance.close() calls socket.destroy(err) after connection pool drain, causing unhandled 'error' event and process crash #576

@MasahiroMorita

Description

@MasahiroMorita

CloudSQLInstance.close() calls socket.destroy(err) after connection pool drain, causing unhandled 'error' event and process crash

Environment

  • @google-cloud/cloud-sql-connector: v1.11.0
  • Node.js: 22.x
  • Database driver: pg (node-postgres)

Description

When connector.close() is called after a pg connection pool has been fully drained via pool.end(), CloudSQLInstance.close() calls socket.destroy(new CloudSQLConnectorError({ code: 'ERRCLOSED', ... })) on each tracked socket. This triggers an 'error' event emission via process.nextTick. By that point, the pg driver has already removed its error listeners from the sockets as part of the pool shutdown, so the 'error' event fires with no listener — causing an uncaught exception that crashes the process.

Steps to Reproduce

  1. Establish a Cloud SQL connection using the connector
  2. Run a query through a pg.Pool
  3. Call await pool.end() to drain the pool
  4. Call connector.close() immediately after

Observed Behavior

CloudSQLConnectorError: The connector was closed.
at CloudSQLInstance.close (cloud-sql-instance.js:259:32)
at Connector.close (connector.js:252:32)
...

This error surfaces as an unhandled 'error' event on the socket (scheduled via process.nextTick inside socket.destroy(err)). In Node.js 15+, unhandled errors escalate to uncaughtException, causing the process to crash.

Expected Behavior

Calling connector.close() after pool.end() should not crash the process. Since the pool has already been drained and all connections cleanly terminated, there are no pending operations on the sockets. Destroying them as part of connector cleanup is correct, but emitting an error event to signal "The connector was closed" is unnecessary and harmful at this point.

Root Cause

In cloud-sql-instance.js, close() iterates over tracked sockets and calls:

socket.destroy(new errors_1.CloudSQLConnectorError({
    code: 'ERRCLOSED',
    message: 'The connector was closed.',
}));

socket.destroy(err) in Node.js schedules an 'error' event via process.nextTick. After pool.end() completes, the pg driver has already torn down its clients and detached error listeners from the underlying sockets. When the scheduled 'error' event fires in the next tick, no listener is present, resulting in an uncaught exception.

Suggested Fix

Call socket.destroy() without an error argument when close() is invoked intentionally, since passing an error is only meaningful when there are pending operations that need to be notified of an abnormal termination:

- socket.destroy(new errors_1.CloudSQLConnectorError({
-     code: 'ERRCLOSED',
-     message: 'The connector was closed.',
- }));
+ socket.destroy();

Alternatively, ensure the sockets have a no-op 'error' listener attached before calling destroy(err):

socket.once('error', () => {}); // prevent unhandled 'error' event
socket.destroy(new errors_1.CloudSQLConnectorError({ ... }));

Context

This was encountered when using firebase-tools v15.18.0, which calls connector.close() (without await) inside a cleanup function after pool.end() in connect.js. The race between the microtask queue resolving connector.close()'s internal operations and the already-drained pool's socket teardown triggers the issue consistently in CI environments running Node.js 22.

Workaround

Patching cloud-sql-instance.js to remove the error argument from socket.destroy() resolves the crash.

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions