Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

Track Rust sandbox exec bugs: async output collection bypasses timeouts

Open
#513 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
55/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
rust
Domain
backend, testing

Research direction

Start in aci_edge_sandboxes/src/async_api.rs at AsyncExecution::wait_with_output, then compare its read order with AgentExecution::wait in aci_edge_sandboxes/src/agent/mod.rs and synchronous collection in aci_edge_sandboxes/src/exec.rs. Reproduce with the supplied stderr fixture and retained stream; done means async collection reaches the 100 ms timeout and returns ExecOutcome::TimedOut. Add coverage alongside the async API and native-agent tests.

Written by the indexing model from the issue text.

Description

bug-hunt confirmed

Hunt scope and revision

  • Repository: microsoft/nvx.
  • Latest origin/dev fetched at the start of this hunt: 9a2721a713a1bb78f0c94c189900cf89a728a26e (2026-10-12).
  • Focus: Rust sandbox execution, stream lifecycle, and async/native-agent timeout handling.
  • Existing repo:microsoft/nvx label:bug-hunt issues were checked. #464 concerns Python control-pipe writes, and #486 concerns live filesystem shares; neither covers async Rust output collection. The previous Rust API tracker #364 is closed and does not have the bug-hunt label.
  • Investigation stopped immediately after the first confirmed bug, approximately 11 minutes into a 45-minute budget. No repository files were edited and no commits were made.

1. Medium: async output collection can bypass native-agent timeout handling

Impact and trigger

AsyncExecution::wait_with_output() can remain pending indefinitely despite a nonzero workload timeout if the caller takes and retains stderr without draining it and a native-agent execution fills that stream's 16 MiB queue. The same execution completes within its timeout through synchronous Execution::wait_with_output() or asynchronous AsyncExecution::wait().

The native-agent wrapper explicitly handles retained, undrained output on timeout, so this is more than the ordinary requirement to drain both streams: the async collecting path prevents that existing timeout handling from being reached.

Source trace (all links pinned to the examined SHA)

The deadlock is:

  1. The native-agent pump fills the retained stderr queue and blocks in OutputSink::write.
  2. Because the pump cannot reach the terminal event, it keeps stdout open.
  3. The async collector blocks reading stdout to EOF.
  4. AgentExecution::wait() is only called after that read completes, so its deadline/stream-close logic never runs.
Expected versus actual

Expected: With a 100 ms workload timeout, async collecting should invoke the same native-agent timeout handling as wait(), close the blocked stream, and finish with ExecOutcome::TimedOut while the taken stderr stream remains retained.

Actual: Collection is still pending after 1,002 ms in the representative run below. Releasing the retained stderr finally unblocks it, but the result is Exited(0) instead of TimedOut because the timeout handler was never activated while the stream was blocked. Three consecutive reruns with the repository's locked Tokio version reproduced the same result (outer watchdog elapsed 1,002-1,019 ms).

Smallest confirmed reproduction

Tested on Windows x64 with Rust 1.95.0. The crate was compiled directly from an external, unmodified git archive snapshot of the SHA above. A deterministic native ABI DLL fixture delivers 32 MiB of stderr as 64 KiB events followed by ExecEnd::Outcome(Exited(0)). Its behavior mirrors the repository's existing Next::OutputThenExit native-agent test fixture, but on stderr.

The production AgentBackend, DLL loader, output pump, queues, and synchronous/asynchronous facade were all exercised unchanged. Only the separately supplied native library was a test double; no VM was required.

The failing public-API sequence is:

let request = ExecRequest::command_line("fixture-stderr-32m")
    .with_timeout(Duration::from_millis(100));
let mut execution = client.exec(sandbox_id.clone(), request).await.unwrap();
let retained_stderr = execution.take_stderr().unwrap();
let mut collecting = Box::pin(execution.wait_with_output());

assert!(tokio::time::timeout(Duration::from_secs(1), &mut collecting)
    .await.is_err());
drop(retained_stderr); // Diagnostic cleanup, not a fix.
let result = collecting.await.unwrap();
assert_eq!(result.outcome, ExecOutcome::Exited(0)); // Incorrect for this blocked timed execution.

Reproduction output:

sync wait_with_output: outcome=TimedOut, elapsed=111ms
async wait: outcome=TimedOut, elapsed=107ms
async wait_with_output: pending_after_1002ms=true, workload_timeout=100ms
after releasing retained stderr: outcome=Exited(0)
Complete standalone Windows reproduction (no NVX source edits or hypervisor)

The DLL is deliberately a native ABI test double, not a VM implementation. It supplies capability/host metadata and a valid sequence of 512 stderr events, each 64 KiB, then an exit event. The defect and both controls use the unchanged production NVX Rust code. Tokio is pinned to the version in the examined repository's lockfile.

From an NVX checkout containing the examined revision, export source outside the worktree:

$root = Join-Path $env:TEMP "nvx-async-timeout-repro-9a2721a7"
New-Item -ItemType Directory -Path $root -Force | Out-Null
git archive --format=zip "--output=$root\nvx.zip" 9a2721a713a1bb78f0c94c189900cf89a728a26e
Expand-Archive -LiteralPath "$root\nvx.zip" -DestinationPath "$root\nvx-src"
New-Item -ItemType Directory -Path "$root\repro" | Out-Null

Save the three files below inside $root\repro, then run:

$env:CARGO_TARGET_DIR = "$root\target"
cargo +1.95.0 build --manifest-path "$root\repro\Cargo.toml" --lib --bin nvx-async-timeout-repro
& "$root\target\debug\nvx-async-timeout-repro.exe" "$root\target\debug\timeout_agent_fixture.dll"

The executable prints all three results and asserts that the two controls return TimedOut, while async collection stays pending after one second and only completes after the retained stderr is released. All handles and executions are released before the process exits.

Cargo.toml
[package]
name = "nvx-async-timeout-repro"
version = "0.0.0"
edition = "2024"

[lib]
name = "timeout_agent_fixture"
path = "shim.rs"
crate-type = ["cdylib"]

[[bin]]
name = "nvx-async-timeout-repro"
path = "main.rs"

[dependencies]
aci_edge_sandboxes = { path = '..\nvx-src\aci_edge_sandboxes', default-features = false, features = ["agent", "async"] }
aci_edge_sandboxes_model = { path = '..\nvx-src\aci_edge_sandboxes\model' }
serde_json = "1.0"
sha2 = "0.10"
tokio = { version = "=1.53.1", features = ["rt-multi-thread", "time", "io-util"] }
shim.rs
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};

use aci_edge_sandboxes::Capabilities;
use aci_edge_sandboxes_model::setup::RuntimeDigests;
use aci_edge_sandboxes_model::wire::{ExecEnd, HostInfo, SANDBOX_ABI_VERSION};
use aci_edge_sandboxes_model::ExecOutcome;

const CHUNK_BYTES: usize = 64 * 1024;
const OUTPUT_CHUNKS: usize = 512;
static NEXT: AtomicU64 = AtomicU64::new(1);
static EXECS: OnceLock<Mutex<HashMap<u64, usize>>> = OnceLock::new();

fn execs() -> &'static Mutex<HashMap<u64, usize>> {
    EXECS.get_or_init(|| Mutex::new(HashMap::new()))
}

#[repr(C)]
pub struct Event {
    kind: u32,
    data: *const u8,
    length: usize,
}

unsafe fn reply(bytes: &[u8], out: *mut *mut u8, length: *mut usize) -> i32 {
    let buffer = bytes.to_vec().into_boxed_slice();
    unsafe {
        *length = buffer.len();
        *out = Box::into_raw(buffer).cast();
    }
    0
}

#[unsafe(no_mangle)]
pub extern "C" fn aci_edge_sandbox_api_version() -> u32 {
    SANDBOX_ABI_VERSION
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn aci_edge_sandbox_host_open(
    _: *const u8, _: usize, host: *mut u64, _: *mut *mut u8, _: *mut usize,
) -> i32 {
    unsafe { *host = 1 };
    0
}

#[unsafe(no_mangle)]
pub extern "C" fn aci_edge_sandbox_host_close(_: u64) -> i32 { 0 }

#[unsafe(no_mangle)]
pub unsafe extern "C" fn aci_edge_sandbox_capabilities(
    _: u64, out: *mut *mut u8, length: *mut usize,
) -> i32 {
    let mut capabilities = Capabilities::new("native-ABI-fixture");
    capabilities.exec.command_line = true;
    capabilities.exec.cancel = true;
    let bytes = serde_json::to_vec(&capabilities).unwrap();
    unsafe { reply(&bytes, out, length) }
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn aci_edge_sandbox_host_info(
    _: u64, out: *mut *mut u8, length: *mut usize,
) -> i32 {
    let info = HostInfo::new(None, RuntimeDigests {
        openvmm: [0; 32], kernel: [0; 32], initrd: [0; 32],
    });
    let bytes = serde_json::to_vec(&info).unwrap();
    unsafe { reply(&bytes, out, length) }
}

macro_rules! query {
    ($($name:ident),+ $(,)?) => {$(
        #[unsafe(no_mangle)]
        pub unsafe extern "C" fn $name(
            _: u64, out: *mut *mut u8, length: *mut usize,
        ) -> i32 {
            unsafe { reply(b"{}", out, length) }
        }
    )+};
}
query!(
    aci_edge_sandbox_probe, aci_edge_exec_close_input, aci_edge_exec_cancel,
    aci_edge_image_list,
);

macro_rules! call {
    ($($name:ident),+ $(,)?) => {$(
        #[unsafe(no_mangle)]
        pub unsafe extern "C" fn $name(
            _: u64, _: *const u8, _: usize, out: *mut *mut u8, length: *mut usize,
        ) -> i32 {
            unsafe { reply(b"{}", out, length) }
        }
    )+};
}
call!(
    aci_edge_sandbox_validate_provision, aci_edge_sandbox_validate_exec,
    aci_edge_sandbox_start, aci_edge_sandbox_stop, aci_edge_sandbox_deprovision,
    aci_edge_sandbox_diagnostics, aci_edge_sandbox_guest_logs,
    aci_edge_image_register, aci_edge_image_verify, aci_edge_image_unregister,
);

#[unsafe(no_mangle)]
pub unsafe extern "C" fn aci_edge_sandbox_provision(
    _: u64, _: *const u8, _: usize, out: *mut *mut u8, length: *mut usize,
) -> i32 {
    unsafe { reply(br#"{"token":"00000000000000000000000000000001"}"#, out, length) }
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn aci_edge_sandbox_exec(
    _: u64, _: *const u8, _: usize, _: *const u8, _: usize,
    exec: *mut u64, _: *mut *mut u8, _: *mut usize,
) -> i32 {
    let handle = NEXT.fetch_add(1, Ordering::Relaxed);
    execs().lock().unwrap().insert(handle, OUTPUT_CHUNKS);
    unsafe { *exec = handle };
    0
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn aci_edge_exec_next(
    exec: u64, _: i64, event: *mut Event,
) -> i32 {
    static CHUNK: [u8; CHUNK_BYTES] = [b'x'; CHUNK_BYTES];
    static END: OnceLock<Vec<u8>> = OnceLock::new();
    let mut executions = execs().lock().unwrap();
    let remaining = executions.get_mut(&exec).unwrap();
    let (kind, data) = if *remaining > 0 {
        *remaining -= 1;
        (2, &CHUNK[..])
    } else {
        (3, &END.get_or_init(|| {
            serde_json::to_vec(&ExecEnd::Outcome(ExecOutcome::Exited(0))).unwrap()
        })[..])
    };
    unsafe { *event = Event { kind, data: data.as_ptr(), length: data.len() } };
    0
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn aci_edge_exec_write(
    _: u64, _: *const u8, length: usize, _: i64,
    written: *mut usize, _: *mut *mut u8, _: *mut usize,
) -> i32 {
    unsafe { *written = length };
    0
}

#[unsafe(no_mangle)]
pub extern "C" fn aci_edge_exec_release(exec: u64) -> i32 {
    execs().lock().unwrap().remove(&exec);
    0
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn aci_edge_buffer_free(data: *mut u8, length: usize) {
    unsafe { drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(data, length))) };
}
main.rs
use std::path::PathBuf;
use std::time::{Duration, Instant};

use aci_edge_sandboxes::agent::{
    AgentConfig, RuntimeFiles, RuntimeSource, SetupConfig,
};
use aci_edge_sandboxes::{
    AciEdgeSandbox, AsyncAciEdgeSandbox, ExecOutcome, ExecRequest, ProvisionRequest,
};
use sha2::{Digest, Sha256};

const WORKLOAD_TIMEOUT: Duration = Duration::from_millis(100);
const OUTER_TIMEOUT: Duration = Duration::from_millis(1000);

fn main() {
    let library = PathBuf::from(std::env::args_os().nth(1).expect("supply the fixture DLL path"));
    let library = std::path::absolute(library).unwrap();
    let approved: [u8; 32] = Sha256::digest(std::fs::read(&library).unwrap()).into();
    let state = library.parent().unwrap().join("fixture-state");
    let runtime_files = RuntimeFiles {
        openvmm: library.clone(),
        kernel: library.clone(),
        initrd: library.clone(),
        approved_sha256: None,
    };
    let setup = SetupConfig::new(&state, RuntimeSource::Files(runtime_files));
    let client = AciEdgeSandbox::agent(AgentConfig::new(setup, &library, approved)).unwrap();
    let sandbox = client.provision(&ProvisionRequest::new()).unwrap().sandbox_id;
    client.start(&sandbox).unwrap();
    let request = || ExecRequest::command_line("fixture-stderr-32m").with_timeout(WORKLOAD_TIMEOUT);

    let started = Instant::now();
    let mut synchronous = client.exec(&sandbox, &request()).unwrap();
    let retained = synchronous.take_stderr().unwrap();
    let output = synchronous.wait_with_output().unwrap();
    println!(
        "sync wait_with_output: outcome={:?}, elapsed={}ms",
        output.outcome, started.elapsed().as_millis(),
    );
    assert_eq!(output.outcome, ExecOutcome::TimedOut);
    drop(retained);

    let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
    rt.block_on(async {
        let asynchronous = AsyncAciEdgeSandbox::new(client.clone());
        let started = Instant::now();
        let mut execution = asynchronous.exec(sandbox.clone(), request()).await.unwrap();
        let retained = execution.take_stderr().unwrap();
        let outcome = tokio::time::timeout(OUTER_TIMEOUT, execution.wait()).await.unwrap().unwrap();
        println!(
            "async wait: outcome={outcome:?}, elapsed={}ms",
            started.elapsed().as_millis(),
        );
        assert_eq!(outcome, ExecOutcome::TimedOut);
        drop(retained);

        let started = Instant::now();
        let mut execution = asynchronous.exec(sandbox.clone(), request()).await.unwrap();
        let retained = execution.take_stderr().unwrap();
        let mut waiting = Box::pin(execution.wait_with_output());
        let timed_out = tokio::time::timeout(OUTER_TIMEOUT, &mut waiting).await.is_err();
        println!(
            "async wait_with_output: pending_after_{}ms={timed_out}, workload_timeout={}ms",
            started.elapsed().as_millis(), WORKLOAD_TIMEOUT.as_millis(),
        );
        drop(retained);
        let output = tokio::time::timeout(OUTER_TIMEOUT, waiting).await.unwrap().unwrap();
        println!("after releasing retained stderr: outcome={:?}", output.outcome);
        assert!(timed_out, "expected to reproduce the collector ordering bug");
        assert_eq!(output.outcome, ExecOutcome::Exited(0));
    });
    client.stop(&sandbox).unwrap();
    client.deprovision(&sandbox).unwrap();
}
Suggested fix

Start/drain both untaken output collectors concurrently with control.wait(), as the synchronous collecting implementation already does. Do not postpone control.wait() until stdout EOF. Preserve output collection, propagate the control/collector errors, and join the collectors.

Add a native-agent-backed async regression test with more than 16 MiB of stderr, a retained/unread taken stderr stream, a short workload timeout, and a bounded outer watchdog. Assert TimedOut arrives without dropping that stream. Also keep the direct wait and synchronous collection controls.

Explored and checked, not filed

  • Traced synchronous and asynchronous exec setup, collection, drop/cancellation handling, in-process queue backpressure, mock execution completion, and native-agent pump/control behavior.
  • Checked dropped async exec cancellation and retained stdin timeout/cancellation paths: existing targeted tests passed; no additional confirmed bug.
  • Checked whether OpenVMM's output pump could block on the 16 MiB queue: its control protocol's 1 MiB aggregate output cap is smaller, so the suspected backpressure issue was not filed.
  • Live filesystem shares and Python control-pipe timeout problems were excluded as already tracked.

Validation and limits

  • Existing tests against the pinned snapshot: 27 passed: 4 async API tests, 14 mock backend tests, 1 fake-OpenVMM workload-outcomes test, and 8 native-agent unit tests.
  • The new standalone reproduction passed its assertions in three consecutive runs with Tokio 1.53.1, matching the examined repository's lockfile. Both timeout-respecting controls completed in 100-111 ms.
  • python -B scripts\nvx.py verify failed because this worktree's OpenVMM submodule is uninitialized. It was not initialized or changed.
  • No real native agent library, guest, KVM/MSHV/WHP VM, or remote host was tested. The confirmed defect is in NVX's Rust async/native-agent wrapper, reproduced using its actual code and a deterministic ABI test double.
  • All source builds, fixture source, and generated artifacts were placed outside the Git worktree.
Dominant language
Python
Stars
380
Forks
24
Avg merge
12h 2m
Merged PRs (30d)
291

Getting set up

This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from microsoft/nvx

All issues in microsoft/nvx

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.