Skip to content

v2.16.2

Choose a tag to compare

@mariuz mariuz released this 20 Sep 17:39
· 34 commits to master since this release

Patch release: db.attachEvent() no longer hangs forever when the auxiliary event connection cannot be opened. A refused, filtered or prematurely closed aux socket is now reported to the attachment callback as an Error, exactly once. No API, wire-protocol or type changes; drop-in upgrade from 2.16.1.

The bug

Firebird database events (POST_EVENT) do not travel over the main connection. db.attachEvent() sends op_connect_request, the server answers with the address and port of a freshly listening auxiliary socket, and the driver then opens a second, independent TCP connection to that address. Only once that aux socket is up can a FbEventManager be created and handed back to the caller.

EventConnection bound the aux socket's connect, close, error and data handlers, but invoked the attachment callback from connect only. A pre-connect failure was recorded in this.error and then went nowhere: no callback, no db error event, no rejection. The consequences:

  • db.attachEvent(cb) never called cb — not with an error, not at all.
  • db.attachEventAsync() returned a promise that never settled, so an await on it blocked its caller indefinitely and the promise itself leaked.
  • The database connection stayed attached and, if it came from a pool, stayed checked out; a service whose event attachment failed at startup silently stopped making progress rather than crashing or retrying.

The failure needs nothing exotic to reproduce. The aux port is chosen by the server (or pinned with RemoteAuxPort) and is not the port the client connected to, so any deployment where the main port is reachable and the aux port is not hits it:

  • Containers. A Firebird container publishing only 3050 answers op_connect_request with an unpublished aux port. The dial is refused immediately.
  • Firewalls and NAT. A rule that DROPs rather than REJECTs the aux port leaves the dial to hit the OS connect timeout — around two minutes on Linux — and then emit ETIMEDOUT. That error was discarded too, so the wait was not two minutes but forever.
  • Address rewriting. When the server reports 0.0.0.0 or ::, the driver substitutes the host from the connection options. If that host resolves somewhere with nothing listening on the aux port, the dial is refused.

The README's events example has always been written as db.attachEvent(function (err, evtmgr) { if (err) throw err; ... }), and Database.attachEvent() internally already branched on an error argument and routed it through doError(). The consumer side was correct; nothing ever produced the error it was waiting for.

The fix

All three pre-connect outcomes are now routed through a single finishConnect() helper, latched by a _connectSettled flag:

  • error before connect — the socket error is passed to the attachment callback, and the half-open socket is destroyed so it neither lingers nor holds the event loop open.
  • close before connect without a preceding error — the callback receives a descriptive Error: Event connection to <host>:<port> closed before connecting. A silent FIN would otherwise be the one path that still resolved to nothing.
  • connect — the callback is invoked with no error, exactly as before.

The latch matters because a refused TCP connection emits both error and close. Without it, the naive fix calls the attachment callback twice, which in turn runs doError() twice and hands the user's callback a second, spurious error after they have already handled the first.

Post-connection behaviour is deliberately untouched: the data path, opcode dispatch, incomplete-packet buffering and FbEventManager handoff are byte-for-byte what they were in 2.16.1.

Behaviour changes

  • db.attachEvent() can now call back with an error. Code that passed a callback ignoring its first argument, and previously just stalled, will now proceed past the callback with an undefined event manager. Check err — the README example always did.
  • db.attachEventAsync() now rejects instead of returning a forever-pending promise. An await that used to hang will throw, which is what a surrounding try/catch or a supervisor expects.
  • Tearing the aux socket down before it connects surfaces as an error rather than silence. That is the correct signal: the attachment did fail.

The error object handed back is Node's own socket error (ECONNREFUSED, ETIMEDOUT, EHOSTUNREACH, …) with its code intact, so callers can branch on err.code to distinguish "aux port not published" from "host unreachable" without string matching.

Not covered

Two related gaps are out of scope for this patch and unchanged:

  • Errors after a successful connect are still only recorded on EventConnection.error; they are not emitted on the database object or delivered to the event callback. An aux socket that dies mid-subscription is still discovered through evtmgr.getState().isEventConnectionOpen rather than being pushed to the application.
  • No client-side dial timeout. A filtered aux port still waits out the operating system's connect timeout before the error arrives. It arrives now, which it did not before, but the driver does not shorten that wait.

Tests

New test/unit/event-connection.test.ts, which runs in the server-independent unit suite:

  • Refused loopback dial. Binds a server on port 0, records the assigned port, closes the server, then points an EventConnection at the now-free port. Asserts the callback receives an Error, that it ran exactly once across both the error and close events, that the connection never reports itself open, and that the partial socket is destroyed.
  • Close with no error event. Uses a mocked socket to emit close twice without any error, asserting the synthesised closed before connecting message, a single callback invocation, and the resulting _isOpened/_isClosed state.

Both tests were confirmed to fail against the 2.16.1 source: the refused-dial case fails by hitting the 10-second test timeout, which is the hang itself, reproduced in the suite. The full CI matrix is green — 16/16 jobs across Node.js 20, 22, 24 and 26 against Firebird 3, 4, 5 and 6-snapshot — and 348 tests across the 26 server-independent files pass locally.

Documentation

The README's events section documented only the happy path: it showed attachEvent() returning a manager and never said what the aux connection is or what has to be reachable for it to open. A new § The auxiliary connection explains that events ride a second TCP connection to a server-chosen port, spells out the container and firewall cases, covers the 0.0.0.0/:: address substitution, and shows the error branch with the socket codes worth distinguishing. A matching FAQ entry points anyone searching for the pre-2.16.2 hang at it.

Upgrading

Drop-in replacement for 2.16.1. Nothing outside db.attachEvent() and db.attachEventAsync() changes, and successful attachments behave identically. If you carry a workaround — a Promise.race() timeout around attachEventAsync(), a watchdog that restarts a worker whose event attachment never returned, or a pre-flight probe of the aux port — you can drop it and handle the error directly. If your deployment was silently failing to attach, expect to start seeing that failure: a container publishing only 3050 needs the aux port published as well, or RemoteAuxPort pinned to a port you do publish.

Thanks to @Gobbo89 for #434.