Skip to content

Fix silent daemon hang from undrained stderr + attachment size limit#2

Open
mikesimone wants to merge 3 commits into
h4x0r:mainfrom
mikesimone:fix/stderr-drain-and-attachment-limit
Open

Fix silent daemon hang from undrained stderr + attachment size limit#2
mikesimone wants to merge 3 commits into
h4x0r:mainfrom
mikesimone:fix/stderr-drain-and-attachment-limit

Conversation

@mikesimone

Copy link
Copy Markdown

Summary

Two related bugs, both found in production while running signal-cli-api with a real attachment-heavy workload (a message-forwarding service fanning out images/video to ~20 destinations):

  • Undrained stderr deadlocks the daemon. daemon::spawn() pipes the managed signal-cli child's stderr (Stdio::piped()), but only reads it during the startup wait loop. Once the daemon reports ready, nothing drains that pipe for the rest of the process's life. A Linux pipe buffer is ~64KB; once signal-cli logs enough to fill it, its write() call blocks and the whole JVM silently stalls - no crash, no OOM, nothing in any log to explain it. From the outside it just looks like the daemon randomly stops responding to JSON-RPC requests. Fixed by spawning a task that continuously reads and logs the child's stderr for the life of the process.

  • Large attachments exceed signal-cli's JSON string limit. Attachments are sent to /v2/send as base64 data URIs embedded directly in the JSON-RPC request. signal-cli parses incoming requests with Jackson, which enforces a hard 20,000,000-character limit on any single JSON string value (StreamReadConstraints.getMaxStringLength()). Any attachment whose base64 form crosses that line (raw files a bit over ~15MB) throws a StreamConstraintsException that signal-cli doesn't handle cleanly - it kills the single shared JSON-RPC connection the whole process depends on, taking down every other in-flight and future request, not just the oversized one. Fixed by decoding data-URI attachments server-side and writing them to a temp file, then passing signal-cli the file path instead (which its attachment field already accepts) - sidesteps the JVM's JSON parser for the bulky part entirely. Temp files are cleaned up once the send RPC call returns.

Together these two bugs compounded badly: the first bug meant the second bug's real error (StreamConstraintsException) was completely invisible in logs until the stderr fix was in place to surface it.

Test plan

  • Full existing test suite passes (267 tests, cargo test --release)
  • Reproduced the original hang against a live signal-cli daemon with a real ~21MB mp4 attachment (consistently killed the connection before this fix)
  • Confirmed the same attachment sends successfully (7.7s, real SUCCESS result from Signal) after this fix, with the daemon staying healthy afterward
  • Confirmed the spilled temp file is cleaned up after the send completes

rpc_timeout was 30s - group sends to larger/busier destinations can
legitimately take longer than that, and a timeout here doesn't cancel
the request already handed to signal-cli, so it was producing spurious
504s for sends that likely succeeded anyway. Raised to 120s.

/v1/search now also accepts a usernames= query param (getUserStatus
supports both recipient and username independently; only recipient was
wired up before).

Also includes the DefaultBodyLimit fix (300MB, was axum's 2MB default)
that was built and deployed 2026-07-11 but never committed.
The managed daemon's stderr was only ever read during the startup wait
loop. Once the daemon signalled ready, nothing drained it again for the
rest of the process's life - a Linux pipe buffer is ~64KB, and once
signal-cli logs enough to fill it (e.g. a large/malformed attachment
triggering warnings or a stack trace), its write() call blocks and the
whole JVM silently stalls. No crash, no OOM, nothing in any log to point
at; it just stops responding to JSON-RPC requests indefinitely.
signal-cli parses incoming JSON-RPC requests with Jackson, which enforces
a hard 20,000,000-character limit on any single JSON string value
(StreamReadConstraints.getMaxStringLength()). Attachments were embedded
as base64 data URIs, so any file whose base64 form crosses that line
(raw size a bit over ~15MB) trips a StreamConstraintsException that
signal-cli doesn't handle cleanly - it kills the one shared JSON-RPC
connection this whole process depends on, not just the offending
request, taking down every other in-flight and future request too.

signal-cli's `attachment` field also accepts a plain local file path, so
decode data-URI attachments and write them to a temp file signal-cli can
read directly instead, sidestepping the JVM's JSON parser for the bulky
part entirely. Temp files are cleaned up once the send RPC call returns,
success or error.
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.

1 participant