vmm: buffer socket serial output for late-connecting clients

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: #7907

Signed-off-by: Max Makarov <maxpain@linux.com>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
This commit is contained in:
Max Makarov
2026-06-06 05:07:14 +00:00
committed by Rob Bradford
parent 3955fdd22a
commit 9889f6d403
2 changed files with 109 additions and 23 deletions

View File

@@ -544,6 +544,10 @@ fn create_api_ioctl_seccomp_rule() -> Result<Vec<SeccompRule>, BackendError> {
Ok(or![and![Cond::new(1, ArgLen::Dword, Eq, FIONBIO as _)?]])
}
fn create_serial_manager_ioctl_seccomp_rule() -> Result<Vec<SeccompRule>, BackendError> {
Ok(or![and![Cond::new(1, ArgLen::Dword, Eq, FIONBIO as _)?]])
}
fn create_signal_handler_ioctl_seccomp_rule() -> Result<Vec<SeccompRule>, BackendError> {
Ok(or![
and![Cond::new(1, ArgLen::Dword, Eq, TCGETS as _)?],
@@ -1046,6 +1050,7 @@ fn serial_manager_thread_rules() -> Result<Vec<(i64, Vec<SeccompRule>)>, Backend
(libc::SYS_exit, vec![]),
(libc::SYS_fcntl, vec![]),
(libc::SYS_futex, vec![]),
(libc::SYS_ioctl, create_serial_manager_ioctl_seccomp_rule()?),
(libc::SYS_madvise, vec![]),
(libc::SYS_mmap, vec![]),
(libc::SYS_munmap, vec![]),
@@ -1054,6 +1059,7 @@ fn serial_manager_thread_rules() -> Result<Vec<(i64, Vec<SeccompRule>)>, Backend
(libc::SYS_recvfrom, vec![]),
(libc::SYS_rt_sigprocmask, vec![]),
(libc::SYS_rt_sigreturn, vec![]),
(libc::SYS_sendto, vec![]),
(libc::SYS_shutdown, vec![]),
(libc::SYS_sigaltstack, vec![]),
(libc::SYS_write, vec![]),

View File

@@ -4,7 +4,7 @@
//
use std::fs::File;
use std::io::Read;
use std::io::{Read, Write};
use std::net::Shutdown;
use std::os::fd::OwnedFd;
use std::os::unix::io::{AsRawFd, FromRawFd};
@@ -129,7 +129,44 @@ pub struct SerialManager {
kill_evt: EventFd,
handle: Option<thread::JoinHandle<()>>,
pty_write_out: Option<Arc<AtomicBool>>,
socket_path: Option<PathBuf>,
socket_console: Option<SocketConsole>,
}
/// A `Write` handle so the serial device and the serial-manager thread can
/// share one `SerialBuffer` (the device holds it as its output sink).
struct SharedSerialBuffer(Arc<Mutex<SerialBuffer>>);
impl Write for SharedSerialBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
#[derive(Clone)]
struct SocketConsole {
path: PathBuf,
/// Persistent ring buffer: captures serial output while no client is
/// connected and replays it on connect. Shared with the serial device's
/// `out` sink and retargeted by the epoll thread.
buffer: Arc<Mutex<SerialBuffer>>,
write_out: Arc<AtomicBool>,
}
impl SocketConsole {
fn attach_client(&self, writer: UnixStream) -> Result<()> {
let mut buffer = self.buffer.lock().unwrap();
buffer.set_out(Box::new(writer));
self.write_out.store(true, Ordering::Release);
buffer.flush().map_err(Error::FlushOutput)
}
fn detach_client(&self) {
self.write_out.store(false, Ordering::Release);
self.buffer.lock().unwrap().set_out(Box::new(io::sink()));
}
}
impl SerialManager {
@@ -219,6 +256,29 @@ impl SerialManager {
.set_out(Some(Box::new(buffer)));
}
// Install the persistent buffer as the device's sink so output produced
// before the first client connects is captured rather than dropped.
let mut socket_console = None;
if let ConsoleTransport::Socket(_) = transport
&& let Some(path) = socket_path
{
let write_out = Arc::new(AtomicBool::new(false));
let buffer = Arc::new(Mutex::new(SerialBuffer::new(
Box::new(io::sink()),
write_out.clone(),
)));
serial
.as_ref()
.lock()
.unwrap()
.set_out(Some(Box::new(SharedSerialBuffer(buffer.clone()))));
socket_console = Some(SocketConsole {
path,
buffer,
write_out,
});
}
// Use 'OwnedFd' to manage lifetime
// SAFETY: epoll_fd is valid
let epoll_fd = unsafe { OwnedFd::from_raw_fd(epoll_fd) };
@@ -230,7 +290,7 @@ impl SerialManager {
kill_evt,
handle: None,
pty_write_out,
socket_path,
socket_console,
}))
}
@@ -278,6 +338,7 @@ impl SerialManager {
let transport = self.transport.clone();
let serial = self.serial.clone();
let pty_write_out = self.pty_write_out.clone();
let socket_console = self.socket_console.clone();
let mut reader: Option<UnixStream> = None;
// In case of PTY, we want to be able to detect a connection on the
@@ -352,6 +413,11 @@ impl SerialManager {
// Accept them, create a reader and a writer.
let (unix_stream, _) =
listener.accept().map_err(Error::AcceptConnection)?;
// Non-blocking so a slow client can't stall
// the vCPU (SerialBuffer re-buffers on WouldBlock).
unix_stream
.set_nonblocking(true)
.map_err(Error::SetNonBlocking)?;
let writer =
unix_stream.try_clone().map_err(Error::CloneUnixStream)?;
@@ -367,7 +433,10 @@ impl SerialManager {
.map_err(Error::Epoll)?;
reader = Some(unix_stream);
serial.lock().unwrap().set_out(Some(Box::new(writer)));
if let Some(ref socket_console) = socket_console {
socket_console.attach_client(writer)?;
}
}
EpollDispatch::File => {
if event.events & libc::EPOLLIN as u32 != 0 {
@@ -375,22 +444,35 @@ impl SerialManager {
let count = match &transport {
ConsoleTransport::Socket(_) => {
if let Some(mut serial_reader) = reader.as_ref() {
let count = serial_reader
.read(&mut input)
.map_err(Error::ReadInput)?;
if count == 0 {
info!("Remote end closed serial socket");
serial_reader
.shutdown(Shutdown::Both)
.map_err(Error::ShutdownConnection)?;
reader = None;
serial
.as_ref()
.lock()
.unwrap()
.set_out(None);
match serial_reader.read(&mut input) {
Ok(0) => {
info!(
"Remote end closed serial socket"
);
serial_reader
.shutdown(Shutdown::Both)
.map_err(
Error::ShutdownConnection,
)?;
reader = None;
if let Some(ref socket_console) =
socket_console
{
socket_console.detach_client();
}
0
}
Ok(count) => count,
// Non-blocking socket with no pending
// input on this wakeup: nothing to do.
Err(e)
if e.kind()
== io::ErrorKind::WouldBlock =>
{
0
}
Err(e) => return Err(Error::ReadInput(e)),
}
count
} else {
0
}
@@ -455,10 +537,8 @@ impl Drop for SerialManager {
if let Some(handle) = self.handle.take() {
handle.join().ok();
}
if let ConsoleTransport::Socket(_) = self.transport
&& let Some(socket_path) = self.socket_path.as_ref()
{
std::fs::remove_file(socket_path.as_os_str())
if let Some(socket_console) = self.socket_console.as_ref() {
std::fs::remove_file(&socket_console.path)
.map_err(Error::RemoveUnixSocket)
.ok();
}