Skip to content

fix: interleaved datagrams on the controller socket - #379

Open
noamtu123 wants to merge 2 commits into
PerformanC:mainfrom
noamtu123:fix/controller-socket-desync
Open

fix: interleaved datagrams on the controller socket#379
noamtu123 wants to merge 2 commits into
PerformanC:mainfrom
noamtu123:fix/controller-socket-desync

Conversation

@noamtu123

Copy link
Copy Markdown

Changes

Commit 1 — fix: interleaved datagrams on the controller socket

zygiskd serializes DAEMON_SET_INFO and DAEMON_SET_ERROR_INFO into a single
buffer and sends each as one datagram. The controller reads a whole datagram and
parses the fields out of it, with bounds checks, instead of reading each field
from the socket separately.

Commit 2 — improve: bound the EAGAIN retry in read_loop_offset (optional)

Bounds the EAGAIN retry in read_loop_offset() with poll() and a deadline.
This one is independent and can be dropped without affecting the first; it is
included because it removes the severity of the failure, not just this cause.

Why

zygiskd_start() sent each field of a message as its own datagram:

unix_datagram_sendto(CONTROLLER_SOCKET, &(uint8_t){ DAEMON_SET_INFO }, sizeof(uint8_t));
unix_datagram_sendto(CONTROLLER_SOCKET, &root_impl_len, sizeof(root_impl_len));
unix_datagram_sendto(CONTROLLER_SOCKET, impl_name, root_impl_len);
...

and rezygiskd_listener_callback() read them back one field at a time. Both the
64-bit and the 32-bit daemon send to this socket, and nothing makes either the
sequence or the socket exclusive to one of them, so the two sequences can
interleave in the receive queue. When they do, a read_uint32_t() can start on
one daemon's 1-byte command datagram and take its remaining 3 bytes from the
next datagram, yielding a length nobody sent.

The lengths observed on a device where this happens decode exactly as
[command][real length][0][0]:

observed length bytes (LE) decodes as
3334 06 0D 00 00 DAEMON64_SET_INFO (6) + 13 ("KernelSU Next")
3335 07 0D 00 00 DAEMON32_SET_INFO (7) + 13 ("KernelSU Next")
2823 07 0B 00 00 DAEMON32_SET_INFO (7) + 11 ("treat_wheel")

The controller then waits for those bytes, which nobody will send. read_loop()
retries on EAGAIN, and since the socket is non-blocking and serviced by the
monitor's single-threaded epoll loop, that retry cannot succeed: the loop that
would deliver more data is the one blocked inside the retry.

Because the monitor holds init under PTRACE_O_TRACEFORK, the result is a boot
hang. With the loop stuck it never reaches epoll_wait(), never drains the
SIGCHLD reporting init's PTRACE_EVENT_FORK, and never sends PTRACE_CONT,
so init stays in t (tracing stop) and the device does not finish booting. On a
non-GKI 4.9 arm64 device this happened on roughly 1 boot in 6.

Datagrams are delivered atomically, so sending each message as one removes the
interleaving. Parsing from the received buffer also bounds every field against
the datagram size, so a malformed message can no longer yield a length that is
not backed by data.

Checkmarks

  • The modified functions have been tested.
  • Used the same indentation as the rest of the project.
  • Updated documentation (changelog). — there is no changelog file in the tree

Additional information

Testing, on the device that reproduces this:

build boots hangs
unmodified 30 ~5
commit 1 only (no poll() bound) 30 0
commit 2 only 81 0
both 20 0

Commit 1 was deliberately tested without commit 2, so the hang could still
occur if the interleaving were not the real cause. state.json also reported
root and the full module list on 31/31 boots afterwards; before the change,
the boots where the desynchronization happened without hanging left it unwritten,
because the read-failure path returns before update_status().

Notes on the change itself:

  • Both sides have to change together, as this is the format the two speak. I
    could not reduce it to one file: with only the sender changed, the
    controller's 1-byte command read consumes and discards the rest of the
    datagram; with only the controller changed, there is no atomic message to read.
  • DAEMON_SET_ERROR_INFO is included for the same reason — leaving it as
    several datagrams while the controller parses from one buffer would silently
    break error reporting.
  • After commit 1, monitor.c no longer uses anything from socket_utils.h. I
    left the include alone rather than mix a cleanup in; note it also provides
    <stdint.h> transitively, so removing it needs that added.
  • TEMP_FAILURE_RETRY is kept where it already was, for consistency with the
    surrounding calls, though the syntax standard lists GNU extensions as
    prohibited.
  • zygiskd builds clean under its -Werror -Wconversion -Wpedantic, and the new
    code adds no warnings under -Wconversion -Wshadow on the loader side either.

If you would rather take only the first commit, or implement it differently, that
is completely fine — the important part is the decode table above, which should
be reproducible anywhere both daemons start close together.

The 64-bit and 32-bit daemons share the controller socket, and each sent its
multi-field messages as several datagrams. The two could interleave, so the
controller paired one daemon's length with the other's payload and derived a
bogus length from it, then waited for bytes that were never sent.

Serialize each message and send it as a single datagram, which is delivered
atomically, and parse the fields out of that datagram instead of reading them
from the socket one at a time. Parsing from the buffer also bounds every field
against the datagram size.
read_loop() is also called from the ptracer's single-threaded epoll callback on
a non-blocking socket. Retrying EAGAIN without a bound cannot succeed there, as
the loop that would deliver the remaining bytes is the one held by the retry, so
a short read becomes a permanent stall rather than a failed message.

Wait for readability with poll() and give up after a deadline, returning the
bytes read so far so the caller's existing error handling runs.

This is independent of the previous commit and can be dropped without affecting
it.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

All Contributors have signed the CLA. The PR is now allowed to be merged.
Posted by the CLA Assistant Lite bot.

@noamtu123

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

performanc-bot added a commit to PerformanC/CLA-Signatures that referenced this pull request Aug 4, 2026
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