Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

Track Rust sandbox exec bugs: async output collection bypasses timeouts

Aperta
#513 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
55/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
rust
Ambito
backend, testing

Direzione di ricerca

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.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

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.
Lingua principale
Python
Stelle
380
Fork
24
Merge medio
12h 3m
PR unite (30g)
292

Preparare l'ambiente

Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di microsoft/nvx

Tutte le issue di microsoft/nvx

Issue simili

Altre issue su Python

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.