Wait for the restart a component deploy triggers, and tell MQTT clients why a publish was refused - #2341
Merged
Conversation
… after deploy+restart
…why when it is not
deploy_component{restart:true} started the worker restart without awaiting it and reported
success immediately. On SO_REUSEPORT platforms every not-yet-replaced worker keeps accepting
connections for the whole rolling restart, so a client that acts on that success lands, per
connection, on a worker still running the pre-deploy component set: its publish is refused
because no resource handles the topic. Measured on Linux with 4 workers: 38 of 870 publishes
refused over the first ~3s, mixed with successes in the same round (harper#2335).
- restartWorkers() now also awaits the replacements it can only start after their predecessor
exits (Windows/macOS/Bun), so awaiting it means the pool is serving the new code everywhere.
- deploy_component awaits the restart, making its success mean what callers already assume.
- a publish refused for an unhandled topic answers 0x90 'Topic Name invalid' with a reason
string instead of a bare 0x80 'Unspecified error', matching what SUBSCRIBE already reports.
- a worker shutdown sends v5 clients DISCONNECT 0x8B 'Server shutting down' instead of
dropping the socket unannounced.
…port unreplaced workers, honor v5 problem-information negotiation
…completion in restart_completed
…QTT reason string in bytes
…te a kept old worker from a short pool
Contributor
There was a problem hiding this comment.
Code Review
This pull request improves the reliability of worker restarts during component deployments, particularly for MQTT topic availability. It ensures that deployments await worker restarts, gracefully disconnects MQTT clients with a 'Server shutting down' reason code during restarts, and maps publish errors to specific MQTT reason codes. Feedback on these changes suggests making the hardcoded 120-second restart timeout dynamic or configurable to accommodate larger thread pools under load, and wrapping property accesses on caught errors in a try/catch block to avoid potential crashes from untrusted objects.
…tartWorkers resolves
… budget A flat cap reports a healthy restart of a wide thread pool as incomplete: each replacement carries its own multi-minute startup backstop, so the roll can legitimately outlast any fixed budget. Wait while the restart keeps replacing workers, give up after 60s without progress, and keep a 10-minute absolute ceiling so a wedged restart cannot hold the operations-API response open.
Contributor
|
Reviewed; no blockers found. |
The progress report sat before the SHUTDOWN post, so a worker draining in-flight work for longer than the idle window looked stalled even though the restart was live. Report on the events that actually mean forward motion: a replacement reporting started, a worker exiting, and a draining worker asking for more time (it only asks while its drain is still moving).
…it open A drain reports its deadline once, when it starts, so an idle window shorter than the drain ceiling read a live drain as a stall. Pass the deadline the worker asked for through to the waiter, which honors it over the idle window while the absolute ceiling still bounds the response. The wait moved to components/awaitRestart.ts with injectable timers so its outcomes are unit-testable.
…alse negative, and wait on drop too
A peer applying a replicated deploy runs it on a worker, where restartWorkers() hands the restart
to the main thread; reporting that as restart_completed: false made four healthy peer restarts
look like partial failures. Leave the field off when this thread did not perform the restart.
Component load can also outlast the idle window on its own, so report progress after it. drop_component
{restart: true} now waits the same way a deploy does — the same race in the removal direction.
Installing and loading every root component reports nothing and can outlast the waiter's idle window on its own, so a slow-but-healthy install read as a stalled restart.
…t timer precision
…distinct from 'handed off' A replacement that never reports started has no predecessor left to fall back on, so leaving it running was a worker slot serving nothing until the process restarted; terminate it (Bun gets the FORCE_EXIT post it needs) and let startWorker replace it. Separately, restartWorkers() also returns nothing from the main thread when the process is already shutting down, which read as a handoff and silently dropped restart_completed; that case now says so.
…o, and throttle on replacement readiness The suppression check compared against 0, but mqtt-packet parses that byte into a boolean, so a client asking for no problem information was still sent a reason string; the new test for it caught that. Also: a worker now counts as replaced once its replacement is accepting connections rather than once it has exited, so on platforms that cannot pre-start one the loop no longer takes the whole pool down while the first replacements boot. drop_component reports its restart outcome through the same log path as a deploy.
…s instead of hanging
kriszyp
marked this pull request as ready for review
August 26, 2026 16:59
dawsontoth
approved these changes
Aug 27, 2026
…sponse field The awaited restart is what makes a successful deploy mean the component is live; a restart_completed field only spoke to the exceptional cases, which the log already names, at the cost of public API surface that could not be narrowed later. Also drops the pool-availability throttle rework, which is a restart-pacing change beyond this fix and ships separately.
…w waits for On a Windows runner the npm install alone spends ~26s of the old 30s budget; with the restart now inside the response the deploy crossed it.
kriszyp
added a commit
that referenced
this pull request
Aug 31, 2026
…ing, not when it exits restartWorkers() throttled on maxWorkersDown by counting a worker as "down" until it exited, not until its replacement was actually serving. Where the replacement can't be pre-started (Windows/macOS/Bun) it only boots after the old worker is gone, so throttling on exit alone let the loop race ahead of booting replacements and take the whole pool down at once. - waitingToFinish now tracks a worker until its replacement is confirmed serving (or given up on), not merely until the old worker exits. - A new replacementsFailedToStart counter stops the restart, leaving the remaining pool on the old code, once too many replacements in a row fail to come up — readiness throttling bounds how many workers are down at once, but not how many outright fail to return. This is the pool-availability throttle rework called out as shipping separately when #2341 (harper#2335) landed.
kriszyp
added a commit
that referenced
this pull request
Sep 1, 2026
…it exits (#2363) * squash: PR#2363 net work for conflict analysis * fix(restart): count a worker as replaced when its replacement is serving, not when it exits restartWorkers() throttled on maxWorkersDown by counting a worker as "down" until it exited, not until its replacement was actually serving. Where the replacement can't be pre-started (Windows/macOS/Bun) it only boots after the old worker is gone, so throttling on exit alone let the loop race ahead of booting replacements and take the whole pool down at once. - waitingToFinish now tracks a worker until its replacement is confirmed serving (or given up on), not merely until the old worker exits. - A new replacementsFailedToStart counter stops the restart, leaving the remaining pool on the old code, once too many replacements in a row fail to come up — readiness throttling bounds how many workers are down at once, but not how many outright fail to return. This is the pool-availability throttle rework called out as shipping separately when #2341 (harper#2335) landed. * fix(restart): don't count an auto-restarted worker as still on old code, avoid index shadow * style: prettier formatting
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
deploy_component { restart: true }fired the worker restart without waiting for it and reported success immediately, so on platforms where replacements share a port (SO_REUSEPORT) every not-yet-replaced worker kept accepting connections while still running the pre-deploy component set — a client acting on that success was served, per connection at random, by a worker that had never heard of the component it just deployed. On MQTT that surfaced as a publish refused with a bare0x80"Unspecified error" and then, when the worker was finally replaced, an unannounced socket close (harper#2335). The deploy now waits for the restart, an unhandled-topic publish is refused with0x90"Topic Name invalid" and a reason string, and a worker that is shutting down sends MQTT v5 clientsDISCONNECT 0x8B"Server shutting down" instead of dropping the socket. Measured on Linux with 4 worker threads: 38 of 870 publishes refused across the first ~3s before, 0 of 876 after.Docs companion: HarperFast/documentation#638. The pool-availability throttle change that was here now ships separately as #2363.
For the human reviewer
restart_completedresponse field; it only spoke to the exceptional cases (the wait gave up, a worker could not be replaced, the process was already shutting down), all of which the log names, and it would have been public API we could not narrow later. So a successful deploy means what the await gives it and nothing more. The cost: a caller cannot distinguish "restarted" from "restart still running" programmatically, and this PR's own test lost a deterministic guard against the wait silently stopping early (the publish fan-out still catches it, less pointedly).restartWorkers()from a worker only hands the restart to its main thread, and the worker that would await it is itself about to be restarted and sever the connection it would answer on. Making a peer report a finished restart needs the originating node to track a job rather than an operation response — recorded here as the known limitation, not attempted.restartWorkers()reports each worker it replaces, beats while root components load, and passes on the deadline a draining worker asks for; the wait gives up after 60s without progress, with a 10-minute absolute ceiling. The first version was a flat 120s cap, which the PR review correctly called out as reporting healthy wide-pool restarts as incomplete. None of the three numbers is configurable — a caller who wants an asynchronous restart already hasrestart: "rolling"and its job id.awaitRestartturns a rejection into{ completed: false }and logs it, so a restart that threw outright is reported like one that merely ran long. The alternative — failing an operation whose component is already on disk and replicated, with no rollback — seemed worse. One line to reverse.drop_component { restart: true }now waits the same way. It is the same race in the removal direction (a dropped component's topic still served by a not-yet-replaced worker), so leaving it asymmetric looked worse than the extra surface; it is a behavior change on a second operation, though, and out of the issue's literal scope.extendTerminateDeadlineinto a held-open deploy, nothing proves a drop-with-restart waits until the removed topic is gone from every worker, nothing publishes QoS 1 while a shutdown lands, and nothing proves an error this layer does not map has its message withheld (the safety half of the reason-string change). Each needs a fixture or a minute-scale test; the mechanisms they cover are exercised by the four integration cases here plus the tenawaitRestartunit cases.Also worth a look: the client notification rides the
ShutdownDrainregistry withhasWork: () => false, a mechanism whose stated purpose is extending the shutdown deadline for in-flight work. It runs beforecloseServers(), which both moves MQTT teardown slightly earlier than where an in-flight publish previously completed and delays the port release a non-SO_REUSEPORTreplacement is waiting on (the hazard already documented for drains inintegrationTests/components/shutdown-drain-e2e.test.ts). A dedicated post-closeServershook would avoid both, at the cost of a second lifecycle hook. Separately, the no-resource error is taggedstatusCode = 404on the session so any protocol layer can map it — an ad-hoc property on a plainErrorthat is now a cross-protocol contract rather than a shared error class.Verification
New integration suite
integrationTests/mqtt/deploy-restart-topic-availability.test.ts(Node, 4 worker threads, MQTT over WebSocket): the deployed topic is live on every worker as soon as the deploy returns; an unhandled-topic publish is refused0x90with a reason string; a client that sentrequestProblemInformation: falsegets the code without the string; a restart sends v5 clientsDISCONNECT 0x8Bwhile a v3.1.1 client is only closed. Fails-on-base againstorigin/mainsources with a forced rebuild, for the right reasons: 32 of 32 publishes refused,expected reason code 0x90, got 128,no DISCONNECT received before the socket closed.unitTests/components/awaitRestart.test.jscovers the wait's outcomes with injected timers: counts passed through, handoff, a declined restart, rejection, a synchronous throw, stall, progress keeping it alive, a reported deadline outliving the idle window, the absolute ceiling, and no timer left armed after it settles. Four assertions were checked against deliberately broken builds — removing the settled guard, movingstartRestartout of the promise chain, restoring the!== 0problem-information check, and dropping the v5 gate on the shutdown DISCONNECT each turn one red. That third one caught a real bug in this PR's first version: mqtt-packet parses the CONNECT byte into a boolean, so comparing it to0never suppressed anything.Before/after on the probes that drove the diagnosis (Linux, 4 threads, one node): fresh connection per publish, 38 of 870 refused over the first ~3s before the change, 0 of 876 after; 8 connections pinned before the deploy and publishing across the restart, 14–20 QoS 1 publishes never settled with zero
disconnectevents before, every client receiving reason code 139 before close after.Suites run on Linux:
integrationTests/{deploy,mqtt,components}195 passed / 0 failed / 1 skipped, andintegrationTests/{deploy,mqtt,components,apiTests}1123 passed / 0 failed / 14 skipped a few commits earlier;integrationTests/server0 failed (6 Ollama-backend cancellations, no local daemon). CI was green on all 44 checks beforeorigin/mainwas merged in. Two runs on that Linux box showed unrelated failures — 9 job timeouts inapiTests/terminology.test.mjs, one Harper startup watchdog incomponents/static-after-rest.test.ts— neither of which reproduces in isolation (48/48 and 7/7) or on an idle box; it also hosts the dev-agent fleet.The reason string is sent only for the failures this layer maps to a code of its own (
statusCode403/404), is trimmed and size-checked in encoded bytes, and is withheld when the client's CONNECT setrequestProblemInformation: falseor advertised amaximumPacketSizetoo small for it. The remaining client-side hang after a shutdown ismqtt.js's own behavior — it flushes pending publish callbacks only on a client-forcedend()withreconnectPeriod: 0— so the DISCONNECT is asserted, not the settling of an in-flight publish.Complexity: medium
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=17 @ e2826aa
Human-Review-Need: 4 @ e2826aa