Skip to content

Release: develop -> main - #211

Merged
TaprootFreak merged 1 commit into
mainfrom
develop
Jul 30, 2026
Merged

Release: develop -> main#211
TaprootFreak merged 1 commit into
mainfrom
develop

Conversation

@github-actions

Copy link
Copy Markdown

Automatic Release PR

This PR was automatically created after changes were pushed to develop.

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

… reconnecting (#210)

* fix(lightning): keep a bad LND WebSocket frame from killing the process

The three LND WebSocket streams were piped through `map(this.mapXMessage)`, which
passes the mappers unbound. RxJS then calls them with `this === undefined`.

The happy path never touches `this`, so this went unnoticed. The `result`-missing
path does: it logs via `this.logger`, which throws a TypeError. RxJS converts a
throw inside a map projection into an error notification, the subscribers register
no error handler, so it reaches `reportUnhandledError` and takes the process down —
tearing down all three WebSocket connections with it.

LND's REST gateway ends a stream with an error frame that carries no `result`, so a
cancelled TrackPayments subscription was enough to trigger it.

- bind the mappers via arrow functions
- route them through `safeMap` so a mapping failure stays local and the stream
  survives instead of terminating
- log the offending frame as JSON; the template literal rendered `[object Object]`

* fix(lightning): keep the WebSocket retry budget from draining silently

Surviving a bad frame instead of crashing only helps if the reconnect path can
still recover. `retryAttempt` was reset solely when a message arrived, never when
a connection opened, so the 30-strike budget drained monotonically for any stream
that reconnects but stays quiet — LND sends no backlog on subscribe. Previously
each such frame killed the process and the restart reset the counter; now the
process survives, so the budget has to be restored on `open` or ingestion stops
for good with nothing erroring and nothing alerting.

Also address two ways the recovery paths could still take the process down:

- `createWebSocket()` reads the TLS certificate per instantiation and throws on a
  missing file. In a bare `setTimeout` that throw is uncaught, and with no socket
  created no `close` event arrives to drive the next attempt. Retry now catches,
  logs and re-arms.
- `JSON.stringify` was the one unguarded statement inside the mapping-failure
  handler, so an unserializable frame reproduced the crash from inside the guard
  meant to prevent it.

`safeMap` now takes a zero-argument mapper: a bare `this.mapXMessage` reference
does not satisfy `() => T`, so the unbound call this fix is about cannot be
written at the call site any more.

Tests: retry-budget restore, retry after a throwing socket construction,
invoice mapping incl. both settle_date branches, and assertions on the logged
frame. Verified all nine fail against the previous behaviour.

* fix(lightning): never stop reconnecting, and make a stuck stream visible

The previous commit reset the retry budget on `open`, which fixed the quiet
stream but left both remaining paths ending in a silent, permanently dead
subscription:

- A connection that opens and drops straight back reset the budget every cycle,
  so it retried forever at info level and the only error the client can emit
  never fired. LND sends no backlog on subscribe, so such a stream delivers
  nothing at all.
- A connection that never opens still gave up for good after 30 attempts. That
  was survivable before only because a broken stream killed the process and the
  restart built fresh connections; without that accidental safety net, an LND
  outage longer than five minutes ended ingestion until the next deploy.

The budget now measures consecutive failures to hold a connection: it resets
only after a connection has stood for a while, so a flap drains it like a failed
connect. Retries no longer stop; instead every 30 attempts without a stable
connection raise an error, so a stuck stream keeps trying and stays visible.

Also:

- register the socket handlers against the socket they belong to rather than the
  field, so a retired socket cannot drive its successor — the same aliasing class
  this branch set out to fix
- make `describe()` total; `String()` throws on a null-prototype object, which
  would have escaped the very handler that keeps mapping failures off the stream
- type the message handler as `WebSocket.Data` and parse via `toString()`; ws
  delivers a Buffer, not a string, and the spec now emits one

Of the tests, 13 fail against the previous behaviour on develop and 5 against the
preceding commit. The earlier claim that all nine of that commit's tests were
discriminating was wrong: four were, the rest characterise existing behaviour.

* fix(lightning): judge stream health by reconnect rate, not consecutive failures

A budget of consecutive failures resets on any good cycle, so a single
connection that holds hides everything around it. A stream alternating 28 short
connections with one long one ran at 17% uptime without logging anything, and a
steady 31s-up/10s-down loop — what an idle timeout in front of LND produces —
reconnected 2107 times in a simulated day, also silently.

Counting reconnects within a rolling hour has no such blind spot, and it covers
both shapes the previous version needed separate handling for: a stream that
cannot connect and one that connects and drops straight back both reconnect far
more often than a healthy stream, which only does so on an LND restart or a
deploy. That removes the hold-duration threshold, the consecutive counter and
its off-by-one after a reset.

Measured against the same simulations: mixed flap 580 reconnects -> 19 errors,
steady loop 2107 -> 70, and a healthy stream reconnecting every two hours for a
week stays silent.

Also: assert the inspected output in the unserializable-frame test rather than
only that logging happened, and drop a comment claiming a test covers the
Buffer message path — JSON.parse already coerced a Buffer, so that change is
type-only.

* fix(lightning): detect the stream deaths a reconnect rate cannot see

Judging health purely by reconnect rate has a floor: 30 reconnects per hour means
any repeating failure cycle of 125 sec. or longer never registers. That band is
occupied. No handshake timeout was set, so a connect attempt against a dropped
(rather than refused) port fails at the TCP SYN timeout, ~127 sec., giving a
137 sec. cycle — 26 reconnects an hour on a stream that is completely dead, and
the previous commit reported it as healthy. The consecutive counter it replaced
had no such floor, so that was a regression within this branch.

Worse, a rate cannot see a stream with no reconnects at all. Without a handshake
timeout a stalled upgrade emits neither 'error' nor 'close', and a half-open
connection never closes either, since ws disables the socket timeout and this
client answers pings but never sends any. Both leave a stream delivering nothing
while every signal reads it as healthy and quiet.

- track how long the stream has been disconnected, reset on 'open'. This is
  immune to how slowly each attempt fails, and covers what the rate cannot
- set handshakeTimeout, so a stalled upgrade becomes an 'error' and a 'close'
  that the retry chain can act on
- ping on an interval and terminate when a pong does not come back, which turns a
  half-open connection into a reconnect
- report the observed span in the reconnect alert rather than the configured
  window, which was constant by construction
- log a repeating construction failure once instead of every 10 sec.; the signals
  above already carry the repetition
- measure with the monotonic clock, so a backwards NTP or DST correction cannot
  fabricate a window's worth of reconnects

Simulated over 24 h: SYN blackhole 630 reconnects, 0 alerts before, 209 now;
half-open goes from silent forever to detected in 60 sec.; a healthy stream
reconnecting every two hours for a week still logs nothing. 18 tests fail against
develop and 5 against the preceding commit.

* fix(lightning): drop the ping heartbeat, throttle the connect error

The heartbeat added in the previous commit rests on LND answering client pings,
and that does not hold. lnd's WebSocket proxy installs no ping handler, so a ping
is answered only by gorilla's default, which runs while the read loop is active
and writes the pong with a one second deadline — and a write timeout is treated
as temporary and swallowed. Tolerating exactly one missed pong then makes any
event-loop stall longer than the interval enough to terminate a healthy stream,
which loses events because LND sends no backlog on subscribe. Detecting a
half-open connection is worth doing, but not on an assumption that cannot be
checked from here and whose failure mode is a reconnect storm on all three
streams at once. Left as a follow-up, noted in the PR.

handshakeTimeout stays: a stalled upgrade emitting neither 'error' nor 'close' is
the one liveness gap that needed no assumption about the peer.

Retrying forever also made the socket 'error' handler unbounded. Previously the
client gave up after 30 attempts and went quiet; now an unreachable LND logs at
error level every 10 sec. indefinitely, which is the same defect the last commit
fixed one line away for the construction failure. Both are now reported once per
outage, and the throttled signals carry the repetition.

Also:

- report the downtime actually observed rather than the configured threshold,
  which was constant by construction — the same fix the last commit applied to the
  reconnect message but not to its sibling. Throttling now uses its own field, so
  re-arming no longer discards when the outage started
- clear the construction latch on a successful construction rather than on a
  later 'open': construction can start succeeding while LND is still down, so
  'open' never arrives and a second, different failure went unreported

Every branch is now mutation-checked: negating either signal, dropping either
throttle, either re-arm, the handshake timeout, the monotonic clock or the socket
binding each break at least one test. 21 tests fail against develop, 5 against the
preceding commit.

* fix(lightning): report a connect error whose cause changes mid-outage

`connectErrorLogged` was a boolean cleared only on 'open', so during an outage
that never opens, only the first cause was ever recorded. LND going down logs
ECONNREFUSED; if it comes back with a rotated macaroon, every upgrade is rejected
with a 401 that never reaches the log, and the operator keeps seeing an hour-old
network error and chases a fault that no longer exists. That is the same
asymmetry the previous commit fixed one line away for the construction failure.

Both latches now key on the cause rather than on a boolean, so a repeat stays
quiet and a change is reported. The set of causes is small and bounded — refused,
timed out, unreachable, handshake rejected — so this cannot grow into per-attempt
logging.

Also closes three gaps that mutation testing found unpinned:

- resetting the outage start on 'open'. Without it a fresh five-minute outage
  after a day of uptime reports the time since the process started, which is the
  constant-by-construction fault this branch has now fixed twice elsewhere
- sending the subscribe body on 'open'. Dropping it leaves the client connected
  and silent, LND with nothing to send back, and both health signals reading a
  dead stream as healthy and quiet — the most complete silent death in the file
- sending it on the socket that opened rather than on the field. Only the pong
  half of that binding was covered, though both are load-bearing: ws throws
  synchronously from send() and pong() while a socket is still connecting

Also drops a test helper left over from the removed heartbeat, and a comment
citing a two-minute connect that handshakeTimeout now bounds.

23 tests fail against develop, 2 against the preceding commit.

* fix(lightning): bound the error log by cause set, not by the last cause

The previous commit keyed both error latches on the cause so that a change mid
outage still reported, and justified it with "the set of causes is small and
bounded, so this cannot grow into per-attempt logging". That does not follow: a
bounded set of size two, alternating, defeats a single remembered value entirely.
A crash-looping LND produces exactly that on the retry cadence — refused while
the process is down, rejected while the REST gateway boots — and measured on the
branch it logged on all 100 of 100 attempts, restoring the per-attempt volume
that commit set out to remove.

Each outage now remembers the causes it has already reported, capped, so a repeat
stays quiet, a change is still reported, and neither alternation nor a message
that varies can push it back to one line per attempt.

errorCause also needed hardening, since it runs inside the catch that keeps the
reconnect chain alive:

- it could throw. String() throws on a null-prototype object, and a throw there
  skips the reschedule and escapes the timer, which is a dead stream plus an
  uncaught exception — the two failure modes this branch exists to remove. The
  sibling helper in the service was already guarded against exactly this
- it collapsed every connect errno onto one key. From Node 20 autoSelectFamily
  is on by default and a connect failure to a hostname arrives as an
  AggregateError whose message is empty, so refused and unreachable were
  indistinguishable. The code is now part of the key. Latent on the pinned Node
  18 base image, live on the next bump

Also states the real reason both health signals exist. handshakeTimeout bounds a
cycle at 25 sec., so a stream that cannot connect does now register as a rate —
the downtime signal earns its place by reporting the size of an outage, reaching
one twice as fast, and not depending on that bound holding.

26 tests fail against develop, 5 against the preceding commit.

* fix(lightning): say so when an outage produces more causes than it reports

Capping the reported causes stopped the per-attempt logging, but saturating is
silent: whichever five causes arrive first become the whole story. Measured with
five gateway errors followed by an expired macaroon, the 401 never appeared once
across 200 attempts, and nothing in the log said anything had been dropped — the
operator reads five transient, self-healing-looking causes and concludes LND is
restarting. That is the wrong-fault trap the cause set was meant to remove,
narrowed from one stale cause to five rather than closed.

A saturated set now says so once. Detection was never affected; this is about the
log being honest about its own completeness.

Also tightens two test assertions that were too loose to pin what they claimed:
the cap tests bounded the count from above only, so a cap of 2, 3 or 4 — or a
regression silencing the log entirely — passed the whole suite. And errors
carrying neither a code nor a message now have a test, since without the String()
fallback they would all collapse onto one key and only the first would be logged.

Two mutants inside the cause-key helper survive and are left there deliberately:
dropping the empty-string filter is behaviourally equivalent, and dropping the
message from the key needs two errors that share a code but differ in message,
which cannot arise against a fixed URL.

27 tests fail against develop, 1 against the preceding commit.

* fix(lightning): prove the mapping guard on all three streams, name the suppressed log

The guard this branch exists for was pinned on one stream of three. Removing
safeMap from the onchain and payment pipes left the whole service suite green,
because those two mappers cannot throw on any input the tests fed them. The frame
`null` is legal — `JSON.parse('null')` — and `null.result` throws in all three,
which is what tells an unguarded pipe from a guarded one. Without it the headline
claim rested on the invoice path alone.

The suppression notice could not say what it was about. Both cause sets share the
reporting helper and emitted identical text, so two saturating in one outage read
as a doubled line rather than one truncated connect log and one truncated retry
log. It now names which. Dropped "this outage" as well: the construction set is
cleared by a successful construction, not by the end of an outage, so the phrase
was wrong on that path.

Saturation is now tracked beside the causes instead of as an entry among them.
The sentinel's justifying comment was wrong — a JavaScript string holds a NUL
perfectly well, and an error message that happened to equal the marker would have
silently disabled the notice, which is the failure it was added to remove.

Two claims of mine in the previous commit did not survive review, both corrected
here rather than argued:

- "dropping the message from the cause key needs two errors that share a code but
  differ in message, which cannot arise against a fixed URL" is false. read and
  write ECONNRESET share a code and differ in message with no DNS involved, and
  Node's connect message carries the resolved address, which changes when a
  container is recreated mid-outage. The mutant is killed by a test now
- errorCause was unpinned at the construction call site — the one its own comment
  is written for — so substituting String() there passed the whole suite while
  reintroducing the throw that skips the reschedule

32 tests fail against develop, 2 against the preceding commit.
@Danswar
Danswar requested a review from TaprootFreak July 30, 2026 15:38
@TaprootFreak
TaprootFreak merged commit 678f7d4 into main Jul 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants