From 4e356cd84a8c5b687c8ea883faecf377adb058d6 Mon Sep 17 00:00:00 2001 From: Justin Nothling Date: Tue, 5 May 2026 13:07:00 +0200 Subject: [PATCH] lianad: fix JSON-RPC server truncating responses larger than the kernel send buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unix-domain JSON-RPC server in `lianad/src/jsonrpc/server/unix.rs` silently truncates response bodies on macOS once they exceed the default `SO_SNDBUF` size (8 KiB). Reproduces deterministically with any `createspend` whose PSBT exceeds ~6 KiB pre-base64 — typical of multipath multi-key descriptors. Two interacting issues: 1. The connection inherits the listener's non-blocking flag on macOS (`accept(2)` propagates `O_NONBLOCK`; Rust's `std` does not normalise this, unlike Linux's `accept4`). 2. The response write path uses `serde_json::to_writer(&stream, ...)`, which calls `Write::write` exactly once. Once the kernel send buffer fills, `write` returns either a short count or `WouldBlock`, the trailing bytes are dropped, and the connection is closed — leaving the client with truncated JSON. Fix: - Force the accepted connection to blocking (`stream.set_nonblocking(false)`) so the kernel back-pressures the writer rather than returning `WouldBlock`. - Serialize the response into a `Vec` first, then `write_all` it. `write_all` loops on short writes; combined with a blocking socket this guarantees all bytes are flushed before the connection closes. Either change in isolation is insufficient on macOS — the `write_all` loop alone still aborts on `WouldBlock` from a non-blocking socket, and the blocking-socket alone still relies on a single `write` call fully accepting the response (which is not guaranteed for any size). Reproduced on macOS 14, aarch64; v13.1 and v14.0 both affected. Verified pre-fix: `createspend` truncated at exactly 8192 bytes mid-base64. Post-fix: full 38628-byte response delivered on the same descriptor + same input. Reported via private email to edouard@wizardsardine.com 2026-04-29 (BufWriter hypothesis — incorrect; the actual mechanism is the one described above). Public disclosure cleared by Edouard 2026-05-05. --- lianad/src/jsonrpc/server/unix.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/lianad/src/jsonrpc/server/unix.rs b/lianad/src/jsonrpc/server/unix.rs index f308a2d4e..d2f99eac1 100644 --- a/lianad/src/jsonrpc/server/unix.rs +++ b/lianad/src/jsonrpc/server/unix.rs @@ -88,6 +88,15 @@ fn connection_handler( mut stream: net::UnixStream, shutdown: sync::Arc, ) -> Result<(), io::Error> { + // The listener is set non-blocking so `accept` can poll for + // shutdown; on macOS the accepted connection inherits that + // flag. Force blocking on the connection so the response write + // below doesn't see `WouldBlock` once the kernel send buffer + // fills (default `SO_SNDBUF` of 8 KiB on macOS), which would + // otherwise truncate any RPC response larger than 8 KiB — + // notably `createspend` PSBTs for any non-trivial multipath + // descriptor. + stream.set_nonblocking(false)?; let mut buf = vec![0; 2048]; let mut end = 0; let mut cursor = 0; @@ -111,7 +120,20 @@ fn connection_handler( let response = api::handle_request(&mut control, req).unwrap_or_else(|e| Response::error(req_id, e)); log::trace!("JSONRPC response: {:?}", serde_json::to_string(&response)); - if let Err(e) = serde_json::to_writer(&stream, &response) { + // Serialize fully then `write_all`, rather than + // `serde_json::to_writer(&stream, ...)` directly. The + // `Write` impl for `&UnixStream` calls `write(2)` once; + // `to_writer` does not loop on short writes, so a response + // partially accepted by the kernel ends up truncated on + // the wire. `write_all` loops until every byte is flushed. + let response_bytes = match serde_json::to_vec(&response) { + Ok(b) => b, + Err(e) => { + log::error!("Error serializing response: '{}'", e); + return Ok(()); + } + }; + if let Err(e) = io::Write::write_all(&mut &stream, &response_bytes) { log::error!("Error writing response: '{}'", e); return Ok(()); }