Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/Build.Linux.Job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ jobs:
target: aarch64-unknown-linux-gnu
features: ''
runs-on: ${{ matrix.runner }}
# A wedged PTY child would otherwise hold the runner for the 6-hour cap.
timeout-minutes: 45
defaults:
run:
working-directory: src/tools/lxc
Expand Down Expand Up @@ -131,6 +133,14 @@ jobs:
cargo test --locked --release --target "${{ matrix.target }}" -p mxc-sdk -p mxc_ffi --lib state_aware
cargo test --locked --release --target "${{ matrix.target }}" -p mxc-sdk --test state_aware

# This lane selects lib tests by module, so mxc_pty needs an explicit
# step; the macOS lane picks it up via the package-wide run. The tests
# drive /dev/ptmx directly and need no sandbox backend installed.
- name: Test unix pty bridge
working-directory: src
run: cargo test --locked --release --target ${{ matrix.target }}
-p mxc-sdk --lib 'core_modules::mxc_pty::'

# These need no LXC installed: the engine tests inject their probes, and
# the dispatch and FFI pins are refused before a container is created.
- name: Test LXC dispatch and discovery
Expand Down
9 changes: 4 additions & 5 deletions .github/workflows/Build.MacOS.Job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ jobs:
build:
name: arm64
runs-on: macos-15
# A wedged PTY child would otherwise hold the runner for the 6-hour cap.
timeout-minutes: 45
defaults:
run:
working-directory: src
Expand Down Expand Up @@ -44,17 +46,14 @@ jobs:
run: cargo build --locked --release --target aarch64-apple-darwin
-p mxc_darwin -p mxc-sdk -p unix_test_proxy -p mxc_ffi

# mxc-sdk includes the Seatbelt executor characterization tests. Its
# consolidated mxc_pty module was not part of this pre-consolidation
# macOS lane, so keep those host-sensitive unit tests out of this job.
# mxc-sdk includes the Seatbelt executor characterization tests.
# mxc-exec-mac (built above via -p mxc_darwin) is what they drive; the
# tests skip via has_platform_exec() if it is missing. sandbox-exec needs
# no elevation, so they run in this standard macOS job.
- name: Test
run: |
cargo test --locked --release --target aarch64-apple-darwin \
-p mxc_darwin -p mxc-sdk -- \
--skip 'core_modules::mxc_pty::tests::'
-p mxc_darwin -p mxc-sdk

- name: Test state-aware SDK boundaries
shell: bash
Expand Down
4 changes: 4 additions & 0 deletions docs/api-reference/dotnet/v1/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ IsolationSession-only. Seatbelt PTY rejects `guiAccess` and legacy
give the caller explicit input, output, resize, and process ownership; there
is no separate public attached-console launch API or API accepting ["MXC request JSON"](../../../schema.md#mxc-request-json).

Read the terminal output concurrently rather than only after waiting. On macOS
a workload cannot exit while its terminal output is still unread, so a caller
that waits first without reading can block indefinitely.

## Discovery and validation

| Operation | Use it to |
Expand Down
4 changes: 4 additions & 0 deletions docs/api-reference/node/v1/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ rejects `guiAccess` and legacy `launchMethod: "open"`.
Terminal handles give the caller explicit input, output, resize, and process
ownership; there is no separate attached-console launch API or API accepting ["MXC request JSON"](../../../schema.md#mxc-request-json).

Read the terminal output concurrently rather than only after waiting. On macOS
a workload cannot exit while its terminal output is still unread, so a caller
that waits first without reading can block indefinitely.

## Discovery and validation

| Operation | Use it to |
Expand Down
4 changes: 4 additions & 0 deletions docs/api-reference/rust/v1/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ IsolationSession-only. Seatbelt PTY rejects `guiAccess` and legacy
caller explicit input, output, resize, and process ownership instead of
attaching the workload to the host process's global console streams.

Read the terminal output concurrently rather than only after `wait`. On macOS
a workload cannot exit while its terminal output is still unread, so a caller
that waits first without reading can block indefinitely.

## Discovery and validation

| Operation | Use it to |
Expand Down
111 changes: 35 additions & 76 deletions src/mxc-sdk/src/core/mxc_common/interruptible_reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@
//! and sets a flag so later reads short-circuit to EOF.

use std::io::{self, Read};
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;

use crate::mxc_common::sandbox_process::StreamCloser;

Expand Down Expand Up @@ -78,7 +77,6 @@ pub struct InterruptibleReader {
/// Read end of the self-pipe; readable once cancellation writes its byte.
wake_r: OwnedFd,
state: Arc<CancelState>,
eof_probe_interval: Option<Duration>,
}

impl InterruptibleReader {
Expand All @@ -92,39 +90,12 @@ impl InterruptibleReader {
/// Returns the underlying [`io::Error`] if the self-pipe cannot be created
/// or descriptor flags cannot be applied.
pub fn new(fd: OwnedFd) -> io::Result<Self> {
Self::new_with_eof_probe_interval(fd, None)
}

/// Wrap an owned readable pipe and periodically probe it for EOF.
///
/// This is needed for Darwin FIFOs, where `poll` may not wake when the
/// final writer closes even though a non-blocking `read` returns EOF.
pub fn new_with_periodic_eof_probe(fd: OwnedFd, interval: Duration) -> io::Result<Self> {
Self::new_with_eof_probe_interval(fd, Some(interval))
}

fn new_with_eof_probe_interval(
fd: OwnedFd,
eof_probe_interval: Option<Duration>,
) -> io::Result<Self> {
set_cloexec(fd.as_raw_fd())?;
set_nonblocking(fd.as_raw_fd())?;

// Self-pipe for wakeups: the write end is non-blocking so `cancel`
// never stalls; the read end stays blocking but is only ever polled.
let mut fds = [0 as RawFd; 2];
// SAFETY: `fds` is a valid 2-element array for `pipe` to fill.
if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: `pipe` succeeded, so both fds are freshly owned by us.
let wake_r = unsafe { OwnedFd::from_raw_fd(fds[0]) };
let wake_w = unsafe { OwnedFd::from_raw_fd(fds[1]) };
// `pipe(2)` doesn't set close-on-exec, so mark both ends `FD_CLOEXEC` β€”
// otherwise they leak into any process this thread later forks+execs
// (e.g. another sandbox child).
set_cloexec(wake_r.as_raw_fd())?;
set_cloexec(wake_w.as_raw_fd())?;
let (wake_r, wake_w) = new_wake_pipe()?;
set_nonblocking(wake_w.as_raw_fd())?;

Ok(Self {
Expand All @@ -134,7 +105,6 @@ impl InterruptibleReader {
cancelled: AtomicBool::new(false),
wake_w,
}),
eof_probe_interval,
})
}

Expand All @@ -143,7 +113,6 @@ impl InterruptibleReader {
pub fn canceller(&self) -> ReadCanceller {
ReadCanceller(Arc::clone(&self.state))
}

pub fn try_clone_owned_fd(&self) -> io::Result<OwnedFd> {
self.fd.as_fd().try_clone_to_owned()
}
Expand Down Expand Up @@ -194,27 +163,14 @@ impl Read for InterruptibleReader {
];
// SAFETY: `poll_fds` is a valid 2-element array of pollfds; both
// fds are owned and live for the duration of the call.
let timeout = self
.eof_probe_interval
.map(|interval| {
interval.as_millis().clamp(1, libc::c_int::MAX as u128) as libc::c_int
})
.unwrap_or(-1);
let rc = unsafe { libc::poll(poll_fds.as_mut_ptr(), 2, timeout) };
let rc = unsafe { libc::poll(poll_fds.as_mut_ptr(), 2, -1) };
if rc < 0 {
let err = io::Error::last_os_error();
if err.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(err);
}
if rc == 0 {
match read_fd(self.fd.as_raw_fd(), buf) {
Ok(count) => return Ok(count),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue,
Err(error) => return Err(error),
}
}

// Cancellation wins over any pending data so a held-open pipe is
// abandoned promptly.
Expand Down Expand Up @@ -260,6 +216,37 @@ fn set_nonblocking(fd: RawFd) -> io::Result<()> {
Ok(())
}

/// Create the wakeup self-pipe with both ends marked close-on-exec.
///
/// Linux gets this atomically from `pipe2`. macOS has no such variant, so the
/// ends are inheritable between creation and the `fcntl` that marks them; a
/// spawn racing on another thread can capture them. The platform offers no
/// atomic alternative, and Rust's own `Stdio::piped()` has the same window on
/// macOS.
fn new_wake_pipe() -> io::Result<(OwnedFd, OwnedFd)> {
#[cfg(target_os = "linux")]
{
nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(io::Error::from)
}
#[cfg(not(target_os = "linux"))]
{
use std::os::fd::FromRawFd;

let mut fds = [0 as RawFd; 2];
// SAFETY: `fds` is a valid 2-element array for `pipe` to fill.
if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: `pipe` succeeded, so both fds are freshly owned by us.
let read = unsafe { OwnedFd::from_raw_fd(fds[0]) };
// SAFETY: as above, for the write end.
let write = unsafe { OwnedFd::from_raw_fd(fds[1]) };
set_cloexec(read.as_raw_fd())?;
set_cloexec(write.as_raw_fd())?;
Ok((read, write))
}
}

/// Add `FD_CLOEXEC` to `fd`'s descriptor flags so it doesn't leak across `exec`.
fn set_cloexec(fd: RawFd) -> io::Result<()> {
// SAFETY: `fd` is a valid open fd; `fcntl` with these commands only reads
Expand All @@ -278,6 +265,7 @@ fn set_cloexec(fd: RawFd) -> io::Result<()> {
mod tests {
use super::*;
use std::io::Write;
use std::os::fd::FromRawFd;
use std::time::{Duration, Instant};

/// Build an `InterruptibleReader` over a fresh pipe, returning it plus the
Expand All @@ -304,35 +292,6 @@ mod tests {
assert_eq!(reader.read(&mut buf).expect("read eof"), 0);
}

#[test]
fn periodic_probe_detects_writer_close_after_data() {
let mut fds = [0 as RawFd; 2];
assert!(unsafe { libc::pipe(fds.as_mut_ptr()) } == 0, "pipe");
let read_end = unsafe { OwnedFd::from_raw_fd(fds[0]) };
let write_end = unsafe { OwnedFd::from_raw_fd(fds[1]) };
let mut reader =
InterruptibleReader::new_with_periodic_eof_probe(read_end, Duration::from_millis(10))
.expect("wrap reader");
let mut writer = std::fs::File::from(write_end);
writer.write_all(b"hello").expect("write");

let mut buf = [0u8; 16];
let n = reader.read(&mut buf).expect("read data");
assert_eq!(&buf[..n], b"hello");

let closer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
drop(writer);
});
let started = Instant::now();
assert_eq!(reader.read(&mut buf).expect("read eof"), 0);
assert!(
started.elapsed() < Duration::from_secs(5),
"periodic EOF probe did not observe writer close"
);
closer.join().expect("writer closer");
}

#[test]
fn zero_length_read_returns_ok_zero_without_blocking() {
// The write end stays open, so a normal read would block; a zero-length
Expand Down
Loading
Loading