vmm: buffer socket serial output for late-connecting clients - #8322
Conversation
phip1611
left a comment
There was a problem hiding this comment.
Generally I think this is a good think! I'm not an expert of the serial_manager code but I think we should make some refactorings here to ensure maintainability. For example, a enum SerialManagerInner could carry the information for the output sync. We should get rid of dozens of Option<T> - this is bad API design and increases cognitive complexity and decreases maintainability
| (libc::SYS_recvfrom, vec![]), | ||
| (libc::SYS_rt_sigprocmask, vec![]), | ||
| (libc::SYS_rt_sigreturn, vec![]), | ||
| // Replaying the buffered backlog to a freshly-connected client is the |
There was a problem hiding this comment.
I think this rather verbose comment doesn't belong here - especially as also all other syscalls do not have comment explaining their context
There was a problem hiding this comment.
Removed — sendto is now bare like the surrounding syscalls.
| })) | ||
| } | ||
|
|
||
| // Put a file descriptor into non-blocking mode using fcntl(). We avoid |
There was a problem hiding this comment.
excessive comment which should not be merged like this.
- non valid rust doc
- not minimal
Put a file descriptor into non-blocking mode using fcntl().is replicating what the code does without much value addwhich is not permitted by the SerialManager-> this is likely to be outdated eventually - further you can change the seccomp filter (which you even did!)
There was a problem hiding this comment.
Trimmed to just the non-obvious part: UnixStream::set_nonblocking() would issue ioctl(FIONBIO), which this thread's seccomp filter rejects, so we use fcntl instead. Dropped the rest.
| fn set_fd_nonblocking(fd: RawFd) -> Result<()> { | ||
| // SAFETY: FFI calls with a valid fd. | ||
| let ret = unsafe { | ||
| let flags = libc::fcntl(fd, libc::F_GETFL); | ||
| libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) | ||
| }; | ||
| if ret < 0 { | ||
| return Err(Error::SetNonBlocking(std::io::Error::last_os_error())); | ||
| } | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
A RawFd doesn't guarantee a valid fd, the SAFETY comment is wrong.
There was a problem hiding this comment.
Fixed — the helper now takes BorrowedFd<'_> instead of RawFd, so the fd is guaranteed live for the duration of the call and the SAFETY comment is actually true.
ef01fe9 to
759cd5a
Compare
|
Thanks both — pushed a revision addressing the feedback:
While re-verifying I also fixed two of my own inaccuracies: the |
| pty_write_out: Option<Arc<AtomicBool>>, | ||
| socket_path: Option<PathBuf>, | ||
| // `Some` only in socket mode; `None` otherwise. | ||
| socket_console: Option<SocketConsole>, |
There was a problem hiding this comment.
This should be split into two commits for better reviewability.
- new struct SocketConsole
- add the new members
There was a problem hiding this comment.
Done — split into the feature commit (socket output buffering) and a separate commit that bundles the state into SocketConsole. I put the feature first because a struct-only commit would be unused (dead code) until it's wired in, so this way every commit builds on its own. Three commits total, including the serial_buffer: set_out() change.
|
Cool! I think it would be worth thinking about if we could also get socket support for virtio-console too? And make sure we don't preclude that in this design. |
759cd5a to
5eb92ab
Compare
|
Good point — the design leans into that rather than precluding it. The reusable piece is So virtio-console-over-socket can reuse the same buffer-while-detached / retarget-and-replay-on-connect pattern. What's serial-specific here is the accept/epoll glue in |
rbradford
left a comment
There was a problem hiding this comment.
I think the code is good but the comments are too much. This is to be expected from an AI generated code (I spend a lot of time cleaning them up in the PRs I produce). I don't need the comment to tell me what the code is doing - I can see that. They should be limited to what is non-obvious.
| /// Replace the downstream writer without discarding buffered bytes. | ||
| /// | ||
| /// On connect the buffer is pointed at the new client (its backlog is then | ||
| /// flushed to it); on disconnect it is pointed at a discarding sink. Either | ||
| /// way the buffered bytes are left in place, so output produced while no | ||
| /// client is attached is preserved for the next client to connect. |
There was a problem hiding this comment.
I don't think this comment is helpful - I feel like this is not reflecting what this method does and instead reflects some other previous behaviour. These kind of comments are unfortunately typical of AI generated code. It's always best to check them and ask it to make sure the comments reflect the code as it currently stands.
| handle: Option<thread::JoinHandle<()>>, | ||
| pty_write_out: Option<Arc<AtomicBool>>, | ||
| socket_path: Option<PathBuf>, | ||
| // `Some` only in socket mode; `None` otherwise. |
There was a problem hiding this comment.
I think this is self evident from the name.
| } | ||
|
|
||
| /// A [`Write`] handle to a shared [`SerialBuffer`], so the serial device (which | ||
| /// owns its `out` sink) and the serial-manager thread (which retargets the |
| /// Socket-console plumbing, present only in `ConsoleTransport::Socket` mode. | ||
| /// Bundled so the fields move together: either all are set or none is, rather | ||
| /// than separate `Option`s that could end up disagreeing. |
There was a problem hiding this comment.
Again - this is a comment reflecting what has changed vs what the code is right now.
| /// connected and replays it on connect. Shared with the serial device's | ||
| /// `out` sink and retargeted by the epoll thread. | ||
| buffer: Arc<Mutex<SerialBuffer>>, | ||
| /// Gates write-through to the connected client; `false` while detached. |
There was a problem hiding this comment.
I think the comment for buffer is good - the rest just look like AI added fluff.
5eb92ab to
c9d9152
Compare
|
Thanks — trimmed all of these to the minimum and pushed. Removed the |
| /// Attach a freshly-accepted client: point the buffer at it, enable | ||
| /// write-through, and replay the captured backlog before live output. |
There was a problem hiding this comment.
I don't think we need a comment to summarise what the code does - we can read that.
There was a problem hiding this comment.
Done — dropped the doc comments on attach_client/detach_client, the SocketConsole struct doc, and the two call-site narrations, and trimmed the new() and non-blocking comments to just the non-obvious bit. Kept only the genuinely explanatory ones (the buffer field, SharedSerialBuffer, and the fcntl-vs-set_nonblocking seccomp note). Pushed.
c9d9152 to
59a3807
Compare
rbradford
left a comment
There was a problem hiding this comment.
Thanks for making those changes I asked for - much appreciated :
| // Use fcntl rather than UnixStream::set_nonblocking(): the latter issues | ||
| // ioctl(FIONBIO), which this thread's seccomp filter does not allow. | ||
| fn set_nonblocking(fd: BorrowedFd<'_>) -> Result<()> { | ||
| let fd = fd.as_raw_fd(); | ||
| // SAFETY: `fd` is borrowed from a live owner, so valid for this call. | ||
| let ret = unsafe { | ||
| let flags = libc::fcntl(fd, libc::F_GETFL); | ||
| libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) | ||
| }; | ||
| if ret < 0 { | ||
| return Err(Error::SetNonBlocking(std::io::Error::last_os_error())); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
There was a problem hiding this comment.
Thanks! But I kinda feel like the right thing to do is probably allow that ioctl in the seccomp filters. We are able to be precise about which ioctl we allow. WDYT?
There was a problem hiding this comment.
Good call — done. Switched to UnixStream::set_nonblocking(true) and added a precise ioctl rule restricted to FIONBIO in serial_manager_thread_rules, mirroring the existing create_api_ioctl_seccomp_rule() (the HTTP-API thread already does exactly this): or![and![Cond::new(1, ArgLen::Dword, Eq, FIONBIO as _)?]]. On Linux set_nonblocking() issues ioctl(fd, FIONBIO, &1), so that's the one syscall the change introduces. Dropped the manual fcntl helper (and its unsafe/BorrowedFd).
Verified under --seccomp true with a real ~38 KB Ubuntu kernel boot: the client gets the full serial log (first kernel line → end), no SIGSYS.
59a3807 to
90ad11b
Compare
|
@maxpain I think the |
dc346c2 to
6af406f
Compare
|
@maxpain The test is still taking too long - https://github.com/cloud-hypervisor/cloud-hypervisor/actions/runs/27008281728/job/79706428355 |
I'll fix this soon. Thanks! |
SerialBuffer owns its downstream writer privately, with no way to replace it. Buffering the Socket console requires keeping one buffer alive across client connects and disconnects and pointing it at each newly accepted client (or a discarding sink when none is connected) without dropping bytes buffered while no client was attached. Add set_out(), which swaps the writer while leaving the buffered contents intact, plus unit tests covering accumulate-while-detached, replay on connect, live pass-through, delivery of while-detached output to the next client, and that bytes already drained by one client are not resent to the next. Signed-off-by: Max Makarov <[email protected]> Assisted-by: Claude:claude-opus-4-8 [Claude Code]
In Socket serial mode the device output sink was only installed once a client connected, so output produced beforehand (kernel boot messages, cloud-init) was dropped, and a client attaching after boot saw a blank screen. Only PTY mode wrapped the sink in a SerialBuffer. Install a persistent SerialBuffer as the Socket device's output sink at SerialManager construction (discarding downstream via io::sink() until a client connects), so output is captured into the 1 MiB ring even with no client attached. On connect, retarget the buffer at the accepted client and flush the backlog before live output resumes; on disconnect, keep buffering so output produced while no client is attached is delivered to the next one. The accepted socket is made non-blocking via set_nonblocking() so a slow client cannot stall the vCPU thread (SerialBuffer re-buffers on WouldBlock). The serial-manager thread gains two syscalls under seccomp: sendto (replaying the backlog is the first time it writes to the socket) and ioctl restricted to FIONBIO, which is what set_nonblocking() issues. Fixes: cloud-hypervisor#7907 Signed-off-by: Max Makarov <[email protected]> Assisted-by: Claude:claude-opus-4-8 [Claude Code]
6af406f to
e37d9ea
Compare
|
@rbradford Fixed — and it turned out to be entirely in the test, not the serial code. Root cause: the test's socat pty echoed back whatever was written to it ( Test-only fix:
The serial code is unchanged from what you approved (I dropped some experimental serial-manager changes I'd pushed while chasing this). |
| let mut received = false; | ||
| loop { | ||
| match ptyc.try_recv() { | ||
| Ok(line) => { | ||
| received = true; | ||
| prev = prev + &line; | ||
| if prev.contains("test_pty_console") { | ||
| return; | ||
| } | ||
| } | ||
| Err(mpsc::TryRecvError::Empty) => break, | ||
| Err(_) => panic!("No login on pty"), | ||
| } | ||
| Err(mpsc::TryRecvError::Empty) => { | ||
| empty += 1; | ||
| assert!(empty <= 5, "No login on pty"); | ||
| } | ||
| _ => { | ||
| panic!("No login on pty") | ||
| } | ||
| } | ||
| if received { | ||
| empty = 0; | ||
| } else { | ||
| empty += 1; | ||
| assert!(empty <= 5, "No login on pty"); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
If we receive 20 lines, and but none of them match "test_pty_console" we still exit from the loop without error. Could this be more defensively written?
There was a problem hiding this comment.
Good catch — fixed: falling out of the bounded loop is now an explicit panic!("No login on pty") instead of a silent return, and I dropped the now-redundant empty counter. Squashed into the test commit.
With socket serial output now buffered and replayed on connect, a late-connecting client receives the whole boot backlog. The pty interaction test had three problems with that: - pty_read() slept a second between 512-byte reads and the loop consumed one chunk per two-second tick, far too slow to drain the backlog. Read in larger chunks without the per-read sleep and drain everything available each round; bound the loop so a missing marker can't run to the harness timeout. - it wrote the login keystrokes before reading, so the unread backlog back-pressured the sender and the keystrokes never reached the prompt. Start reading concurrently with typing instead. - the socat pty was created with echo on, so the replayed backlog was echoed back to the guest as serial input, flooding it (UART input overrun, login never completing). Create the pty with echo=0. Signed-off-by: Max Makarov <[email protected]> Assisted-by: Claude:claude-opus-4-8 [Claude Code]
e37d9ea to
7475663
Compare
Problem
In Socket serial mode (
--serial socket=...), Cloud Hypervisor only installs theserial device's output sink when a client connects. Output produced before that —
kernel boot messages, cloud-init, etc. — is dropped, so a console client that
attaches after boot (e.g. a web serial console in an IaaS) sees a blank screen
with no prior output. Only PTY mode buffers output (via
SerialBuffer). See #7907.Fix
Reuse the existing
SerialBuffer(1 MiB ring) for Socket mode:SerialBufferas the Socket device's output sink atSerialManagerconstruction, discarding downstream (io::sink()) until aclient connects — so output is captured into the ring even with no client.
before live output resumes.
fcntl(notUnixStream::set_nonblocking, whoseioctl(FIONBIO)is not in theserial-manager seccomp filter) so a slow/stalled client can't stall the vCPU
thread —
SerialBufferre-buffers onWouldBlock.sendtoin the serial-manager seccomp filter: replaying the backlog isthe first time that thread writes to the socket.
Two commits: the
serial_bufferAPI addition (set_out+ unit tests), then thevmmwiring. PTY mode is unchanged.Verification
Built
--features kvm, run with seccomp on (default). Boot a guest with--serial socket=...and no client attached; once it is past boot and idle,connect a late client:
Linux version …→Kernel panic … VFS: Unable to mount root fs)Also verified: connecting during boot streams live output (vCPU write-through
works under seccomp); reconnect after disconnect still replays retained history
(unit test);
--serial ptystill boots;cargo build/rustfmt/clippyareclean and the
serial_bufferunit tests pass.This is the first of two PRs for the socket-console work; #7974 (console persists
across guest reboot) will build on this persistent buffer.
Fixes #7907
🤖 Prepared with Claude Code; all changes were reviewed and verified by the author (see the
Assisted-by:commit trailers).