george larson

work

Case study · n8n

The n8n stuck-channel hunt

The problem

n8n's external task-runner mode had a failure nobody could see. The broker and the runner kept a healthy WebSocket (/healthz returned 200, ping/pong passed) while the application-level message channel was dead. Every task request timed out at 60 seconds, forever. The issue (#29372) documented stuck windows of ten to thirty-seven hours. Every monitor said the system was fine. It wasn't.

The framing

The broker already knew. It threw a TaskRunnerAcceptTimeoutError every time a runner failed to acknowledge a task, then logged it and dropped it on the floor. No per-runner failure tracking, no line connecting "this runner stopped answering" to the recovery machinery that already existed. The signal was there. Nothing listened to the pattern.

The right fix wasn't a new monitoring system; it was teaching the broker to hear what it was already saying. Same shape the maintainers had accepted before, in #25959: surface real health on existing signals, don't bolt on a parallel watcher.

The fix

Count consecutive accept-timeouts per runner. After three in a row (about six seconds at the default two-second accept timeout), emit a runner:unresponsive event with the offending runner's id. The WebSocket server disconnects that one runner through the existing removeConnection() path, and the launcher restarts it the same way it does after any connection loss. Attribution is per-runner, so in a multi-runner deployment only the stuck one drops; healthy peers are untouched. Six new unit tests cover the threshold, the per-runner isolation, and clean unsubscribe on stop.

The outcome

Detection of that failure mode drops from hours to about six seconds. Submitted to n8n as PR #29516. The diagnosis is grounded in the code path line by line; the manual repro needs a sustained cloud deployment, which is named as a boundary in the PR rather than glossed over. The scope is honest about what it doesn't catch: a runner that goes quiet without ever accepting an offer never trips the counter, flagged as a follow-up, not hidden.

Three months on, a maintainer closed it in favour of PR #35456. A separate rework had changed the same acceptOffer path in the meantime, so the original patch no longer applied cleanly. #35456 re-implements the consecutive-timeout disconnect on current master and carries it further: reject and defer replies reset the counter too, the runner is reported exactly once at the threshold, and the disconnect gets its own runner-unresponsive reason for observability. It merged on 4 August, credits the diagnosis by name, and shipped to the 2.33 and 2.34 release lines.

PR #29516 · PR #35456 · issue #29372


Hiring, or stuck on something? Let's talk.