Track Rust sandbox exec bugs: async output collection bypasses timeouts
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 55/100
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
Hunt scope and revision
- Repository:
microsoft/nvx. - Latest
origin/devfetched 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-huntissues 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 thebug-huntlabel. - 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)
- AsyncExecution::wait_with_output reads stdout to EOF before calling control.wait.
- AgentExecution::wait implements timeout expiry and closes retained output sinks.
- The agent output pump writes events into the sinks and retains stdout until it reaches the end event.
- The stream queue limit and blocking producer, blocking write.
- Synchronous collection starts collectors and calls control.wait immediately.
- Existing native-agent test verifies timeout closure of retained output, but calls control.wait directly.
- Existing async tests use MockBackend rather than the native-agent wrapper.
The deadlock is:
- The native-agent pump fills the retained stderr queue and blocks in
OutputSink::write. - Because the pump cannot reach the terminal event, it keeps stdout open.
- The async collector blocks reading stdout to EOF.
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 verifyfailed 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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from microsoft/nvx
-
bug-hunt confirmed
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 25/100
Maintainers usually reply within 1 day
-
Track host telemetry bugs: Azure metadata trickles bypass timeoutPossibly taken @jay23606 claimed this 1 day ago. Openbug-hunt confirmed
Difficulty 3/5 1-2 days Newbie friendliness 66/100
Maintainers usually reply within 1 day
-
bug-hunt confirmed
Difficulty 4/5 3-5 days Newbie friendliness 48/100
Maintainers usually reply within 1 day
-
bug-hunt confirmed
Difficulty 3/5 1-2 days Newbie friendliness 55/100
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
onyx-dot-app/onyx#16016 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
black --check exits 0 and prints All done when a multi file run is interruptedPossibly taken @jiteshgulati claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 2 days
-
Non-positive worker counts wait for results without starting workersPossibly taken @IlyaKhalafi claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Maintainers usually reply within 1 day