Skip to content
Open
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
145 changes: 133 additions & 12 deletions crates/native-sidecar/src/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ use crate::state::{
JavascriptTlsDataValue, JavascriptTlsMaterial, JavascriptUdpFamily, JavascriptUdpSocketEvent,
JavascriptUnixListenerEvent, KernelSocketReadinessEvent, KernelSocketReadinessRegistry,
KernelSocketReadinessTarget, LoopbackTlsPendingWriteHandle, LoopbackTlsPendingWriteState,
NetworkResourceCounts, PendingTcpSocket, PendingUnixSocket, ProcNetEntry, ProcessEventEnvelope,
NetworkResourceCounts, PendingKernelStdin, PendingTcpSocket, PendingUnixSocket, ProcNetEntry,
ProcessEventEnvelope,
PythonHostSocket, ResolvedChildProcessExecution, ResolvedTcpConnectAddr, SharedBridge,
SharedSidecarRequestClient, SidecarKernel, SocketQueryKind, ToolExecution, VmDnsConfig,
VmListenPolicy, VmState, DEFAULT_JAVASCRIPT_NET_BACKLOG, EXECUTION_DRIVER_NAME,
Expand Down Expand Up @@ -471,6 +472,7 @@ impl ActiveProcess {
kernel_pid,
kernel_handle,
kernel_stdin_writer_fd: None,
pending_kernel_stdin: PendingKernelStdin::default(),
tty_master_fd: None,
runtime,
detached: false,
Expand Down Expand Up @@ -7885,6 +7887,11 @@ where
{
return Ok(false);
}
// Top off the child's stdin pipe from any host-side backlog before
// probing readiness: the child draining the pipe is exactly what frees
// capacity for the next queued stdin bytes (and, once the backlog is
// empty, executes a deferred stdin close so EOF arrives in order).
flush_pending_kernel_stdin(kernel, child)?;
let now = Instant::now();
let requested_timeout_ms = match request.method.as_str() {
"__kernel_stdin_read" => parse_kernel_stdin_read_args(request)?.1,
Expand Down Expand Up @@ -19509,6 +19516,20 @@ fn install_kernel_stdin_pipe(kernel: &mut SidecarKernel, pid: u32) -> Result<u32
agentos_kernel::fd_table::FD_CLOEXEC,
)
.map_err(kernel_error)?;
// Non-blocking writes only: the sidecar dispatch thread feeds this pipe
// from `write_kernel_process_stdin` / `flush_pending_kernel_stdin`, and a
// blocking pipe write would deadlock — the child's pipe reads are parked
// sync RPCs serviced by this same thread. A full pipe surfaces EAGAIN and
// the remainder is queued in `ActiveProcess::pending_kernel_stdin`.
kernel
.fd_fcntl(
EXECUTION_DRIVER_NAME,
pid,
write_fd,
agentos_kernel::fd_table::F_SETFL,
agentos_kernel::fd_table::O_NONBLOCK,
)
.map_err(kernel_error)?;
Ok(write_fd)
}

Expand All @@ -19533,6 +19554,13 @@ fn javascript_child_process_stdin_mode(request: &JavascriptChildProcessSpawnRequ
.unwrap_or("pipe")
}

/// Host-side cap on stdin bytes queued for a child's kernel pipe. Matches the
/// kernel's default per-write bound (`resource.max_fd_write_bytes`); a parent
/// cannot park more than this behind a slow child before getting a typed
/// error.
const MAX_PENDING_KERNEL_STDIN_BYTES: usize =
agentos_kernel::resource_accounting::DEFAULT_MAX_FD_WRITE_BYTES;

pub(crate) fn write_kernel_process_stdin(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
Expand All @@ -19548,17 +19576,103 @@ pub(crate) fn write_kernel_process_stdin(
let Some(writer_fd) = process.kernel_stdin_writer_fd else {
return Ok(());
};
kernel
.fd_write(EXECUTION_DRIVER_NAME, process.kernel_pid, writer_fd, chunk)
.map_err(kernel_error)?;
// For a TTY process the master write above drives line-discipline echo into
// the master output buffer. Drain it now and surface it as the single
// ordered Stdout stream so typed-character echo reaches the host even while
// the guest is blocked in read() and not producing output of its own.
if let Some(echo) = drain_tty_master_output(kernel, process)? {
process.queue_pending_execution_event(ActiveExecutionEvent::Stdout(echo))?;
}
forward_tty_slave_input_to_javascript(kernel, process)?;
if process.tty_master_fd.is_some() {
kernel
.fd_write(EXECUTION_DRIVER_NAME, process.kernel_pid, writer_fd, chunk)
.map_err(kernel_error)?;
// For a TTY process the master write above drives line-discipline echo
// into the master output buffer. Drain it now and surface it as the
// single ordered Stdout stream so typed-character echo reaches the
// host even while the guest is blocked in read() and not producing
// output of its own.
if let Some(echo) = drain_tty_master_output(kernel, process)? {
process.queue_pending_execution_event(ActiveExecutionEvent::Stdout(echo))?;
}
forward_tty_slave_input_to_javascript(kernel, process)?;
return Ok(());
}
// Pipe-backed stdin. The kernel pipe caps at MAX_PIPE_BUFFER_BYTES and
// fd_write reports POSIX partial writes, so anything the pipe cannot take
// right now is queued and flushed as the child drains (see
// `flush_pending_kernel_stdin`). Silently dropping the remainder is how
// multi-buffer stdin payloads (e.g. git's spooled pack piped into
// `index-pack --stdin`) used to truncate at 64 KiB.
let pending_total = process.pending_kernel_stdin.total;
if pending_total.saturating_add(chunk.len()) > MAX_PENDING_KERNEL_STDIN_BYTES {
return Err(SidecarError::InvalidState(format!(
"child process stdin backlog limit exceeded: {pending_total} pending + {} new bytes \
> {MAX_PENDING_KERNEL_STDIN_BYTES} (MAX_PENDING_KERNEL_STDIN_BYTES); the child is \
not draining stdin — write smaller chunks after the child consumes them, or raise \
this bound together with the kernel resource.max_fd_write_bytes limit",
chunk.len(),
)));
}
process.pending_kernel_stdin.push(chunk);
flush_pending_kernel_stdin(kernel, process)
}

/// Write as much queued stdin as the child's kernel pipe can take right now.
/// Called on every stdin write and from the child kernel-wait servicing path
/// (each parked `__kernel_stdin_read` / `__kernel_poll` re-check), so the
/// backlog drains in lockstep with the child's reads. Executes the deferred
/// stdin close once the backlog is empty.
pub(crate) fn flush_pending_kernel_stdin(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
) -> Result<(), SidecarError> {
if process.tty_master_fd.is_some() {
return Ok(());
}
let Some(writer_fd) = process.kernel_stdin_writer_fd else {
process.pending_kernel_stdin.clear();
process.pending_kernel_stdin.close_requested = false;
return Ok(());
};
while let Some(front) = process.pending_kernel_stdin.chunks.pop_front() {
let offset = process.pending_kernel_stdin.front_offset;
let slice = &front[offset..];
match kernel.fd_write(EXECUTION_DRIVER_NAME, process.kernel_pid, writer_fd, slice) {
Ok(written) if written >= slice.len() => {
process.pending_kernel_stdin.total =
process.pending_kernel_stdin.total.saturating_sub(slice.len());
process.pending_kernel_stdin.front_offset = 0;
}
Ok(written) => {
// Pipe is full mid-chunk; keep the remainder queued.
process.pending_kernel_stdin.total =
process.pending_kernel_stdin.total.saturating_sub(written);
process.pending_kernel_stdin.front_offset = offset + written;
process.pending_kernel_stdin.chunks.push_front(front);
break;
}
Err(error) if error.code() == "EAGAIN" => {
process.pending_kernel_stdin.chunks.push_front(front);
break;
}
Err(error) if error.code() == "EPIPE" => {
// Reader side is gone (child exited or closed stdin): drop the
// backlog and release the writer, mirroring a SIGPIPE'd POSIX
// writer.
process.pending_kernel_stdin.clear();
process.pending_kernel_stdin.close_requested = false;
process.kernel_stdin_writer_fd = None;
let _ = kernel.fd_close(EXECUTION_DRIVER_NAME, process.kernel_pid, writer_fd);
return Ok(());
}
Err(error) => {
process.pending_kernel_stdin.chunks.push_front(front);
return Err(kernel_error(error));
}
}
}
if process.pending_kernel_stdin.is_empty() && process.pending_kernel_stdin.close_requested {
process.pending_kernel_stdin.close_requested = false;
if let Some(writer_fd) = process.kernel_stdin_writer_fd.take() {
kernel
.fd_close(EXECUTION_DRIVER_NAME, process.kernel_pid, writer_fd)
.map_err(kernel_error)?;
}
}
Ok(())
}

Expand Down Expand Up @@ -19605,6 +19719,13 @@ pub(crate) fn close_kernel_process_stdin(
kernel: &mut SidecarKernel,
process: &mut ActiveProcess,
) -> Result<(), SidecarError> {
if !process.pending_kernel_stdin.is_empty() && process.kernel_stdin_writer_fd.is_some() {
// Queued stdin has not reached the pipe yet; closing now would hand
// the child a premature EOF. `flush_pending_kernel_stdin` performs the
// close once the backlog drains.
process.pending_kernel_stdin.close_requested = true;
return Ok(());
}
let Some(writer_fd) = process.kernel_stdin_writer_fd.take() else {
return Ok(());
};
Expand Down
38 changes: 38 additions & 0 deletions crates/native-sidecar/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,11 +449,49 @@ impl Default for VmListenPolicy {
// Active process state
// ---------------------------------------------------------------------------

/// Stdin bytes accepted from a parent's `child_process.write_stdin` but not
/// yet written into the child's kernel stdin pipe. The kernel pipe holds at
/// most `MAX_PIPE_BUFFER_BYTES` (64 KiB) and `fd_write` reports partial
/// writes with POSIX pipe semantics, so multi-buffer stdin payloads (for
/// example git's spooled pack fed to `index-pack --stdin`) must be queued
/// host-side and flushed as the child drains its pipe. `close_requested`
/// defers the writer-fd close until the backlog fully drains so the child
/// never observes an early EOF.
#[derive(Default)]
pub(crate) struct PendingKernelStdin {
pub(crate) chunks: VecDeque<Vec<u8>>,
/// Bytes of the front chunk already written into the pipe.
pub(crate) front_offset: usize,
/// Total unwritten bytes across all queued chunks.
pub(crate) total: usize,
pub(crate) close_requested: bool,
}

impl PendingKernelStdin {
pub(crate) fn is_empty(&self) -> bool {
self.chunks.is_empty()
}

pub(crate) fn push(&mut self, chunk: &[u8]) {
self.total += chunk.len();
self.chunks.push_back(chunk.to_vec());
}

pub(crate) fn clear(&mut self) {
self.chunks.clear();
self.front_offset = 0;
self.total = 0;
}
}

#[allow(dead_code)]
pub(crate) struct ActiveProcess {
pub(crate) kernel_pid: u32,
pub(crate) kernel_handle: KernelProcessHandle,
pub(crate) kernel_stdin_writer_fd: Option<u32>,
/// Backlog for pipe-backed kernel stdin awaiting pipe capacity; see
/// [`PendingKernelStdin`].
pub(crate) pending_kernel_stdin: PendingKernelStdin,
/// For a TTY (PTY-backed) process, the master-end fd whose output buffer
/// carries cooked-mode echo plus ONLCR-processed guest output. When set,
/// this master output is the single ordered output stream surfaced to the
Expand Down
6 changes: 6 additions & 0 deletions crates/native-sidecar/tests/fixtures/limits-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -1114,5 +1114,11 @@
"path": "crates/execution/src/wasm.rs",
"class": "invariant",
"rationale": "Host-side LRU entry cap for cached wasm module bytes; process-wide cache sizing, not per-VM policy."
},
{
"name": "MAX_PENDING_KERNEL_STDIN_BYTES",
"path": "crates/native-sidecar/src/execution.rs",
"class": "policy-deferred",
"rationale": "Host-side backlog cap for child stdin awaiting kernel pipe capacity; mirrors resource.max_fd_write_bytes, not yet wired to VmLimits."
}
]
Binary file modified packages/runtime-core/commands/git
Binary file not shown.
Binary file modified packages/runtime-core/commands/git-receive-pack
Binary file not shown.
Binary file modified packages/runtime-core/commands/git-remote-http
Binary file not shown.
Binary file modified packages/runtime-core/commands/git-remote-https
Binary file not shown.
Binary file modified packages/runtime-core/commands/git-upload-archive
Binary file not shown.
Binary file modified packages/runtime-core/commands/git-upload-pack
Binary file not shown.
11 changes: 10 additions & 1 deletion toolchain/c/scripts/build-git-upstream.sh
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,23 @@ CURL_CFLAGS="-I$CURL_PREFIX/include"
CURL_LDFLAGS="-L$CURL_PREFIX/lib -lcurl -L$MBEDTLS_LIBDIR -lmbedtls -lmbedx509 -lmbedcrypto -L$ZLIB_BUILD_DIR -lz -L$BROTLI_LIBDIR -lbrotlidec -lbrotlicommon -L$ZSTD_LIBDIR -lzstd"

echo "Building upstream Git ${VERSION} for wasm32-wasip1 (with in-process libcurl)..."
# Stack layout: wasm-ld defaults to a 64 KiB stack placed *above* the data
# segment. Git assumes native-sized stacks (recv_sideband alone puts a
# LARGE_PACKET_MAX (~64 KiB) buffer on the stack, reached several frames deep
# on every sideband pack transfer), so with the default layout the buffer
# silently overwrites static data and clones/fetches die with wild wasm traps
# once packs carry payloads bigger than a few KiB. Give git an 8 MiB stack
# (glibc-default sized) and --stack-first so any future overflow traps at the
# faulting frame instead of corrupting the data segment.
GIT_WASM_STACK_FLAGS="-Wl,-z,stack-size=8388608 -Wl,--stack-first"
make -j"${MAKE_JOBS:-2}" \
uname_S=WASI \
CC="$CC_CMD" \
HOSTCC="${HOSTCC:-cc}" \
AR="$AR_CMD" \
RANLIB="$RANLIB_CMD" \
CFLAGS="--target=wasm32-wasip1 --sysroot=$SYSROOT -I$ZLIB_DIR -O2 -D_WASI_EMULATED_PROCESS_CLOCKS -D_WASI_EMULATED_MMAN" \
LDFLAGS="--target=wasm32-wasip1 --sysroot=$SYSROOT -L$ZLIB_BUILD_DIR -lwasi-emulated-process-clocks -lwasi-emulated-mman" \
LDFLAGS="--target=wasm32-wasip1 --sysroot=$SYSROOT -L$ZLIB_BUILD_DIR -lwasi-emulated-process-clocks -lwasi-emulated-mman $GIT_WASM_STACK_FLAGS" \
CURL_CFLAGS="$CURL_CFLAGS" \
CURL_LDFLAGS="$CURL_LDFLAGS" \
CSPRNG_METHOD=getentropy \
Expand Down
Binary file modified toolchain/c/sysroot/lib/wasm32-wasi/libc.a
Binary file not shown.