baadc729 fix(core): bound watcher reconnects after repeated framing failures
Closing the socket on a framing failure let the watcher channels reach
their recovery path, but that path re-dials unconditionally. The only
guard, fileWatcherReconnecting, is cleared immediately before the
recursive call, so it bounds concurrent reconnects rather than
iterations, and the two terminal exits it does have are for an
unavailable server and a version mismatch. A framing failure against a
healthy daemon reaches neither.
A framing failure is deterministic, so the redial replays it: fail ->
onError -> close -> 'close' -> reconnect -> re-register -> fail. This
PR supplies the trigger, since NX_MAX_MESSAGE_SIZE set below a
workspace's file-change batch size makes every notification exceed it.
Measured against a peer that answers every connection with a bad frame,
an unbounded build reconnects 9,373 times in 1.5s.
Each watcher channel now counts consecutive framing failures, resets on
a delivered message, and past three falls into the existing "give up and
notify 'closed'" branch. Socket errors are unaffected and still retry:
only MessageFramingError increments the counter, because a dropped
connection is exactly the case a redial does fix. baadc729 fix(core): bound watcher reconnects after repeated framing failures
Closing the socket on a framing failure let the watcher channels reach
their recovery path, but that path re-dials unconditionally. The only
guard, fileWatcherReconnecting, is cleared immediately before the
recursive call, so it bounds concurrent reconnects rather than
iterations, and the two terminal exits it does have are for an
unavailable server and a version mismatch. A framing failure against a
healthy daemon reaches neither.
A framing failure is deterministic, so the redial replays it: fail ->
onError -> close -> 'close' -> reconnect -> re-register -> fail. This
PR supplies the trigger, since NX_MAX_MESSAGE_SIZE set below a
workspace's file-change batch size makes every notification exceed it.
Measured against a peer that answers every connection with a bad frame,
an unbounded build reconnects 9,373 times in 1.5s.
Each watcher channel now counts consecutive framing failures, resets on
a delivered message, and past three falls into the existing "give up and
notify 'closed'" branch. Socket errors are unaffected and still retry:
only MessageFramingError increments the counter, because a dropped
connection is exactly the case a redial does fix. 2ab8069c fix(core): bound watcher reconnects after repeated framing failures
Closing the socket on a framing failure let the watcher channels reach
their recovery path, but that path re-dials unconditionally. The only
guard, fileWatcherReconnecting, is cleared immediately before the
recursive call, so it bounds concurrent reconnects rather than
iterations, and the two terminal exits it does have are for an
unavailable server and a version mismatch. A framing failure against a
healthy daemon reaches neither.
A framing failure is deterministic, so the redial replays it: fail ->
onError -> close -> 'close' -> reconnect -> re-register -> fail. This
PR supplies the trigger, since NX_MAX_MESSAGE_SIZE set below a
workspace's file-change batch size makes every notification exceed it.
Measured against a peer that answers every connection with a bad frame,
an unbounded build reconnects 9,373 times in 1.5s.
Each watcher channel now counts consecutive framing failures, resets on
a delivered message, and past three falls into the existing "give up and
notify 'closed'" branch. Socket errors are unaffected and still retry:
only MessageFramingError increments the counter, because a dropped
connection is exactly the case a redial does fix. a3c29560 fix(core): surface framing failures instead of hanging the request
Review of the length-prefix change found two defects it introduced, both
of which present as a hang rather than an error.
The idle timer could kill a healthy stream. Node runs the timers phase
before poll, so a reader blocked inside its own data handler past the
deadline had the overdue timer fire before the bytes it was waiting on
were delivered. `fail()` then discarded those bytes and marked the
stream permanently broken. `server.ts` is exactly that case: a
sufficiently large workspace keeps the daemon synchronously inside a
data handler while another client has a partial message buffered. The
timer is removed rather than repaired; a peer that dies closes the
socket, which already surfaces through 'close', and the guard it was
aimed at is the one case it could not observe.
No consumer passed `onError`, so a framing failure only reached
`console.error` while the socket stayed open and writable. Nothing
settled the in-flight request, and the CLI waited out the 20 minute
keep-alive before reporting a handler timeout that named the handler
rather than the transport. All six call sites now forward it: the
messenger rejects the pending request, the daemon and pseudo-IPC report
and close so the peer sees the disconnect, and the plugin worker exits
so its host rejects the pending hooks.
Also from review: the daemon's second invalid-payload path still
interpolated a raw Buffer, a zero-length frame delivered an empty
message that `parseMessage` then threw on, and a rejection from
`handleMessage` was unhandled and would take the daemon down with it. aed78054 fix(core): render binary daemon messages as hex in error output
Both error paths that quote an unparseable message decoded it as utf8.
A v8 payload is binary, so that replaced every byte above 0x7f with
U+FFFD, starting with the 0xFF header that identifies the format. The
excerpt exists to show what arrived, and it destroyed exactly the bytes
worth seeing.
`describeMessage` renders JSON as text and v8 as hex, and reports how
many of the message's bytes it is showing. Truncating utf8 at an
arbitrary byte leaves a partial sequence that decodes to U+FFFD, so the
window is trimmed to whole characters first. A slice taken from the end
can begin mid-character as well as end mid-character, so both edges are
trimmed. Both the client's deserialize failure and the daemon's
invalid-payload error use it.
Also adds `sendMessage(socket, data, force?)`, since every caller that
was not already holding bytes wrote `writeMessage(socket, serialize(x))`.
It lives in socket-utils rather than inside `writeMessage` so the
framing stays a byte-level primitive: consume-messages-from-socket is
shared by callers that already have bytes, and having it reach into the
daemon's serializer would invert the dependency. aed78054 fix(core): render binary daemon messages as hex in error output
Both error paths that quote an unparseable message decoded it as utf8.
A v8 payload is binary, so that replaced every byte above 0x7f with
U+FFFD, starting with the 0xFF header that identifies the format. The
excerpt exists to show what arrived, and it destroyed exactly the bytes
worth seeing.
`describeMessage` renders JSON as text and v8 as hex, and reports how
many of the message's bytes it is showing. Truncating utf8 at an
arbitrary byte leaves a partial sequence that decodes to U+FFFD, so the
window is trimmed to whole characters first. A slice taken from the end
can begin mid-character as well as end mid-character, so both edges are
trimmed. Both the client's deserialize failure and the daemon's
invalid-payload error use it.
Also adds `sendMessage(socket, data, force?)`, since every caller that
was not already holding bytes wrote `writeMessage(socket, serialize(x))`.
It lives in socket-utils rather than inside `writeMessage` so the
framing stays a byte-level primitive: consume-messages-from-socket is
shared by callers that already have bytes, and having it reach into the
daemon's serializer would invert the dependency.