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
1 change: 1 addition & 0 deletions .agents/skills/update-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ Write the doc update following the rules in `docs/CONTRIBUTING.mdx`. Key reminde
- **Use `sidebar-title` for short nav labels**. For explicit navigation entries, keep relative `slug` values in `docs/index.yml` instead of page frontmatter.
- **Keep explicit `page:` entries in `docs/index.yml`**. Fern still requires them. If the page defines `sidebar-title`, set `page:` to that value. Otherwise set `page:` to the page frontmatter `title`.
- **Use `skip-slug: true` in `docs/index.yml`** when a child page should live at the parent section path.
- **Keep each page URL equal to its file path** under `docs/`. Rename the file when you rename a page, add a `fern/docs.yml` redirect for the old URL, and set a relative `slug:` when the nav label does not produce the file name. `mise run docs` runs `docs:nav`, which fails otherwise.
- **Use `keywords` as a comma-separated string**.
- **Do not add a duplicate H1**. Fern renders the page title from frontmatter.
- **Always write NVIDIA in all caps.** Wrong: Nvidia, nvidia.
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/branch-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
- "fern/**"
- "mise.toml"
- "tasks/docs.toml"
- "tasks/test.toml"
- "tasks/scripts/check_docs_nav.py"
- "tasks/scripts/check_docs_nav_test.py"
- ".github/workflows/branch-docs.yml"
- ".github/workflows/release-tag.yml"

Expand Down Expand Up @@ -47,6 +50,18 @@ jobs:
working-directory: ./fern
run: fern check

- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
with:
version: "0.10.12"
python-version: "3.13"

- name: Test docs navigation check
run: uv run --no-project --with pytest --with pytest-asyncio --with pyyaml pytest tasks/scripts/check_docs_nav_test.py

- name: Check docs navigation matches file paths
run: uv run tasks/scripts/check_docs_nav.py

- name: Generate preview URL
if: ${{ steps.fern-preview.outputs.enabled == 'true' }}
id: generate-docs
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ field design, and schema-evolution rules.

## Documentation

If your change affects user-facing behavior (new flags, changed defaults, new features, bug fixes that contradict existing docs), update the relevant pages under `docs/` in the same PR and adjust `docs/index.yml` if navigation changes. For explicit navigation entries, keep `page:` aligned with `sidebar-title` when present and put relative `slug:` values in `docs/index.yml`. Reserve frontmatter `slug` for folder-discovered pages or absolute URL overrides.
If your change affects user-facing behavior (new flags, changed defaults, new features, bug fixes that contradict existing docs), update the relevant pages under `docs/` in the same PR and adjust `docs/index.yml` if navigation changes. For explicit navigation entries, keep `page:` aligned with `sidebar-title` when present and put relative `slug:` values in `docs/index.yml`. Reserve frontmatter `slug` for folder-discovered pages or absolute URL overrides. Keep every page URL equal to its file path under `docs/`; `mise run docs` checks this with `docs:nav`.

To ensure your doc changes follow NVIDIA documentation style, use the `update-docs-from-commits` skill.
It scans commits, identifies doc pages that need updates, and drafts content that follows the style guide in `docs/CONTRIBUTING.mdx`.
Expand Down
6 changes: 4 additions & 2 deletions architecture/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,8 +584,10 @@ See `CI.md` for the contributor workflow, labels, and maintainer merge-queue wor
Published docs live in `docs/`. Navigation lives in `docs/index.yml`. Fern site
configuration, components, theme assets, and publish settings live in `fern/`.

Use `mise run docs` for strict validation and `mise run docs:serve` for local
preview. PR previews are produced by `.github/workflows/branch-docs.yml` when
Use `mise run docs` for Fern validation and navigation-to-file-path consistency,
and `mise run docs:serve` for local preview. The docs PR workflow also runs the
navigation check's unit tests (`mise run test:docs-nav`).
PR previews are produced by `.github/workflows/branch-docs.yml` when
Fern credentials are available. Production docs publish from the release tag
workflow. Redirect rules follow the mutable snapshot that owns their source URL
(or destination for unversioned aliases). Syncing replaces that channel's rules,
Expand Down
5 changes: 5 additions & 0 deletions architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ whose compute resource is still provisioning without exposing contradictory publ
readiness signals. When the driver reports runtime readiness, its ready condition
is published without waiting for a supervisor session.

The supervisor keeps retrying session establishment while the gateway is unavailable.
Its control readiness socket remains absent until the gateway accepts a session and
is removed if that session disconnects. A transient gateway delay during startup
therefore leaves the sandbox provisioning without terminating the supervisor.

**Session precedence over lagging driver snapshots:** A supervisor session can only be
established by a running workload. When `set_supervisor_session_state` promotes the
store record to `Ready` on session connect, a driver watch event may still arrive
Expand Down
5 changes: 5 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ When the admitted main process exits, its status and retained terminal output
remain available. The confirmed sandbox and supervisor-owned access plane continue
to serve policy-authorized exec and loopback forwarding until explicit stop or
delete tears down the boundary and terminates any remaining workload processes.
When the sandbox is PID 1, it reaps adopted workload children after child-exit
notifications, with a low-frequency recovery sweep. Children owned by an
explicit process waiter remain registered and are never consumed by the orphan
reaper. This keeps idle sandboxes from scanning procfs continuously while
preserving wait results and eventual zombie cleanup.

Completed exec output handles can be reclaimed, but execution request IDs remain
reserved for the boundary generation. The sandbox accepts at most 4,096 exec
Expand Down
171 changes: 168 additions & 3 deletions crates/openshell-sandbox/src/managed_children.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,34 @@
//! from incidental zombies.

#![cfg(target_os = "linux")]
#![allow(unsafe_code)]

use std::collections::{HashMap, HashSet};
use std::io;
use std::sync::atomic::AtomicI32;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex, MutexGuard};
use std::time::Duration;

static MANAGED_CHILDREN: LazyLock<Mutex<HashMap<i32, u64>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
static REAPER_EVENT_FD: AtomicI32 = AtomicI32::new(-1);
const REAPER_RECOVERY_INTERVAL: Duration = Duration::from_secs(30);
#[cfg(test)]
static REAPER_SCAN_COUNT: AtomicU64 = AtomicU64::new(0);

extern "C" fn notify_reaper_on_sigchld(_: libc::c_int) {
let fd = REAPER_EVENT_FD.load(Ordering::Acquire);
if fd >= 0 {
let wakeup = 1_u64;
// SAFETY: write is async-signal-safe; the process-lifetime eventfd is
// nonblocking, so a full counter cannot stall the signal handler.
unsafe {
libc::write(fd, (&raw const wakeup).cast(), size_of::<u64>());
}
}
}

/// Identity of one registry entry. The generation prevents an old waiter from
/// removing a newer child that reused the same numeric PID after reap.
Expand Down Expand Up @@ -110,22 +128,87 @@ pub fn wait_until_terminal(pid: u32) -> io::Result<()> {
/// Explicitly managed children remain owned by their normal waiters. Only
/// unregistered children adopted from the workload process tree are reaped.
pub fn start_orphan_reaper() -> io::Result<()> {
// This is installed before the boundary starts its runtime threads or
// workload children. The descriptor and handler live until process exit.
// SAFETY: eventfd has scalar arguments and returns a new descriptor.
let fd = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
REAPER_EVENT_FD.store(fd, Ordering::Release);
let action = nix::sys::signal::SigAction::new(
nix::sys::signal::SigHandler::Handler(notify_reaper_on_sigchld),
nix::sys::signal::SaFlags::SA_RESTART | nix::sys::signal::SaFlags::SA_NOCLDSTOP,
nix::sys::signal::SigSet::empty(),
);
// SAFETY: the handler only reads a lock-free atomic and writes to the
// nonblocking eventfd, both valid for the remaining process lifetime.
unsafe { nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGCHLD, &action) }
.map_err(io::Error::other)?;
std::thread::Builder::new()
.name("openshell-orphan-reaper".to_string())
.spawn(|| {
.spawn(move || {
loop {
if let Err(error) = reap_unmanaged_children_once() {
tracing::debug!(%error, "orphan reaper scan failed");
}
std::thread::sleep(Duration::from_millis(50));
if let Err(error) = wait_for_reaper_event(fd) {
tracing::debug!(%error, "orphan reaper notification failed");
std::thread::sleep(REAPER_RECOVERY_INTERVAL);
}
}
})
.map(|_| ())
}

fn wait_for_reaper_event(fd: libc::c_int) -> io::Result<()> {
let mut notification = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
// SAFETY: poll receives one valid pollfd for the process-lifetime eventfd.
let ready = unsafe {
libc::poll(
&raw mut notification,
1,
i32::try_from(REAPER_RECOVERY_INTERVAL.as_millis()).unwrap(),
)
};
if ready < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
return Ok(());
}
return Err(error);
}
if ready == 0 {
return Ok(());
}
if notification.revents & libc::POLLIN == 0 {
return Err(io::Error::other("orphan reaper eventfd is not readable"));
}
let mut count = 0_u64;
// Drain before scanning: a SIGCHLD arriving during the next scan remains
// queued and triggers another pass. A single read drains coalesced writes.
// SAFETY: read writes one u64 into valid storage; the fd is nonblocking.
let read = unsafe { libc::read(fd, (&raw mut count).cast(), size_of::<u64>()) };
if read == isize::try_from(size_of::<u64>()).expect("u64 size fits isize")
|| (read < 0 && io::Error::last_os_error().kind() == io::ErrorKind::WouldBlock)
{
Ok(())
} else if read < 0 {
Err(io::Error::last_os_error())
} else {
Err(io::Error::other("short orphan reaper eventfd read"))
}
}

fn reap_unmanaged_children_once() -> io::Result<usize> {
use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid};

#[cfg(test)]
REAPER_SCAN_COUNT.fetch_add(1, Ordering::Relaxed);
let children = direct_child_pids()?;
let registry = lock();
let mut reaped = 0;
Expand Down Expand Up @@ -170,7 +253,89 @@ mod tests {
use nix::unistd::Pid;
use std::process::Command;
use std::sync::mpsc;
use std::time::Duration;
use std::time::{Duration, Instant};

fn fork_exiting_child() -> i32 {
// SAFETY: the child does not run Rust code after fork; it exits through
// libc immediately, avoiding inherited test-harness locks.
let pid = unsafe { libc::fork() };
if pid == 0 {
// SAFETY: terminate without unwinding or touching Rust state.
unsafe { libc::_exit(0) };
}
assert!(pid > 0, "fork failed: {}", io::Error::last_os_error());
pid
}

fn wait_until(mut condition: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(5);
while !condition() {
assert!(Instant::now() < deadline, "condition did not become true");
std::thread::sleep(Duration::from_millis(10));
}
}

#[test]
fn orphan_reaper_waits_while_idle_and_reaps_only_unmanaged_children() {
const HELPER_ENV: &str = "OPENSHELL_ORPHAN_REAPER_TEST_HELPER";
if std::env::var_os(HELPER_ENV).is_none() {
let output = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"managed_children::tests::orphan_reaper_waits_while_idle_and_reaps_only_unmanaged_children",
"--nocapture",
])
.env(HELPER_ENV, "1")
.output()
.expect("start isolated reaper test process");
assert!(
output.status.success(),
"reaper test failed: {}",
String::from_utf8_lossy(&output.stdout)
);
return;
}

start_orphan_reaper().expect("start orphan reaper");
wait_until(|| REAPER_SCAN_COUNT.load(Ordering::Relaxed) > 0);
let idle_scan_count = REAPER_SCAN_COUNT.load(Ordering::Relaxed);
std::thread::sleep(Duration::from_millis(150));
assert_eq!(REAPER_SCAN_COUNT.load(Ordering::Relaxed), idle_scan_count);

let mut registry = lock();
let managed_pid = fork_exiting_child();
let managed = registry
.register(u32::try_from(managed_pid).unwrap())
.unwrap();
drop(registry);
wait_until(|| {
matches!(
waitid(
Id::Pid(Pid::from_raw(managed_pid)),
WaitPidFlag::WEXITED | WaitPidFlag::WNOHANG | WaitPidFlag::WNOWAIT
),
Ok(WaitStatus::Exited(..))
)
});
wait_until(|| REAPER_SCAN_COUNT.load(Ordering::Relaxed) > idle_scan_count);
assert!(matches!(
waitpid(Pid::from_raw(managed_pid), None),
Ok(WaitStatus::Exited(..))
));
unregister(managed);

let orphan_pids: Vec<i32> = (0..8).map(|_| fork_exiting_child()).collect();
wait_until(|| {
let children = direct_child_pids().expect("inspect direct children");
orphan_pids.iter().all(|pid| !children.contains(pid))
});
for pid in orphan_pids {
assert_eq!(
waitpid(Pid::from_raw(pid), Some(WaitPidFlag::WNOHANG)),
Err(nix::errno::Errno::ECHILD)
);
}
}

#[test]
fn fast_child_remains_waitable_after_orphan_reap_attempt() {
Expand Down
25 changes: 5 additions & 20 deletions crates/openshell-supervisor-process/src/delegated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ pub async fn start_boundary_access(

let (session_task, session_readiness) = match (openshell_endpoint, sandbox_id) {
(Some(endpoint), Some(id)) => {
let (task, mut accepted) = crate::supervisor_session::spawn_with_readiness(
let (task, accepted) = crate::supervisor_session::spawn_with_readiness(
endpoint.to_string(),
id.to_string(),
ssh_socket_path,
Expand All @@ -168,25 +168,10 @@ pub async fn start_boundary_access(
session_id_updates: supervisor_session_updates,
},
);
let accepted_result =
tokio::time::timeout(Duration::from_secs(10), accepted.wait_for(|ready| *ready))
.await
.map(|result| result.map(|_| ()));
match accepted_result {
Ok(Ok(())) => (Some(task), Some(accepted)),
Ok(Err(_)) => {
task.abort();
return Err(miette::miette!(
"supervisor session ended before gateway acceptance"
));
}
Err(_) => {
task.abort();
return Err(miette::miette!(
"gateway did not accept supervisor session within 10 seconds"
));
}
}
// Session establishment retries through gateway restarts. The
// readiness socket remains absent until the gateway accepts the
// session, so a transient delay cannot kill the supervisor.
(Some(task), Some(accepted))
}
_ => (None, None),
};
Expand Down
36 changes: 24 additions & 12 deletions crates/openshell-supervisor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,21 +100,24 @@ impl ControlReadiness {
path: std::path::PathBuf,
mut session_readiness: Option<tokio::sync::watch::Receiver<bool>>,
) -> Result<Self> {
if session_readiness
prepare_control_readiness_path(&path)?;
let listener = if session_readiness
.as_ref()
.is_some_and(|readiness| !*readiness.borrow())
{
return Err(miette::miette!(
"supervisor session is not ready when starting health listener"
));
}
prepare_control_readiness_path(&path)?;
let listener = tokio::net::UnixListener::bind(&path)
.into_diagnostic()
.wrap_err_with(|| format!("bind supervisor readiness socket on {}", path.display()))?;
None
} else {
Some(
tokio::net::UnixListener::bind(&path)
.into_diagnostic()
.wrap_err_with(|| {
format!("bind supervisor readiness socket on {}", path.display())
})?,
)
};
let task_path = path.clone();
let task = tokio::spawn(async move {
let mut listener = Some(listener);
let mut listener = listener;
loop {
let session_unready = session_readiness
.as_ref()
Expand Down Expand Up @@ -4754,10 +4757,19 @@ mod tests {
async fn control_readiness_tracks_supervisor_session() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("health.sock");
let (session_tx, session_rx) = tokio::sync::watch::channel(true);
let (session_tx, session_rx) = tokio::sync::watch::channel(false);
let _readiness = ControlReadiness::start(path.clone(), Some(session_rx))
.expect("start readiness listener");
check_control_readiness(&path).expect("accepted session is ready");
assert!(check_control_readiness(&path).is_err());

session_tx.send_replace(true);
timeout(Duration::from_secs(1), async {
while check_control_readiness(&path).is_err() {
tokio::task::yield_now().await;
}
})
.await
.expect("first accepted session creates readiness socket");

session_tx.send_replace(false);
timeout(Duration::from_secs(1), async {
Expand Down
Loading
Loading