1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
//! VM readiness checks — waiting for exec socket.
use a3s_box_core::error::{BoxError, Result};
use crate::grpc::ExecClient;
use super::VmManager;
const DEFAULT_EXEC_READY_TIMEOUT_MS: u64 = 15_000;
const EXEC_READY_PROGRESS_LOG_MS: u64 = 5_000;
fn parse_exec_ready_timeout_ms(value: Option<&str>) -> u64 {
value
.and_then(|raw| raw.parse::<u64>().ok())
.filter(|timeout| *timeout > 0)
.unwrap_or(DEFAULT_EXEC_READY_TIMEOUT_MS)
}
fn exec_ready_timeout_ms() -> u64 {
parse_exec_ready_timeout_ms(std::env::var("A3S_EXEC_READY_TIMEOUT_MS").ok().as_deref())
}
impl VmManager {
/// Confirm the VM didn't fail on launch (for generic OCI images without an agent).
///
/// A bad config makes libkrun exit within milliseconds, so we only need a short
/// window to catch an *immediate* crash and fail loudly. Poll for that instead
/// of a fixed 1 s sleep — it shaved ~750 ms off every boot. Crashes that happen
/// later are caught by `wait_for_exec_ready`'s `has_exited` checks, which gate
/// the rest of boot anyway.
pub(crate) async fn wait_for_vm_running(&self) -> Result<()> {
// This is a crash-detection grace period, not a readiness wait: the VM
// process is alive the instant the shim is spawned, and we just watch for it
// exiting immediately. A snapshot-restored VM reaches its run loop in ~20ms
// (no cold boot), so a short grace catches an immediate restore failure while
// saving ~200ms on the fork fast-path; a cold boot keeps the longer grace.
#[cfg(unix)]
let max_wait_ms: u64 = if super::is_restore_mode(&self.config) {
40
} else {
250
};
#[cfg(not(unix))]
let max_wait_ms: u64 = 250;
const POLL_MS: u64 = 10;
tracing::debug!("Confirming VM process started");
let start = std::time::Instant::now();
loop {
if let Some(ref handler) = *self.handler.read().await {
// has_exited is zombie-aware (a halted VM's shim becomes a zombie);
// is_running's kill(pid,0) would still report it alive.
if handler.has_exited() {
return Err(BoxError::BoxBootError {
message: "VM process exited immediately after start".to_string(),
hint: Some("Check console output for errors".to_string()),
});
}
}
if start.elapsed().as_millis() >= max_wait_ms as u128 {
break;
}
tokio::time::sleep(tokio::time::Duration::from_millis(POLL_MS)).await;
}
tracing::debug!("VM process is running");
Ok(())
}
/// Wait for the exec server to become ready (a Frame Heartbeat round-trip).
///
/// Waits for the readiness EVENT — a successful heartbeat — bounded by VM
/// liveness, instead of guessing a fixed timeout. guest-init binds the exec
/// socket early (before the slow network bring-up and container spawn), so the
/// host connect succeeds immediately and the heartbeat passes the moment the
/// guest's accept loop runs — however late in a slow cold boot. Each attempt
/// is individually time-bounded (the early-bound socket makes a host `connect`
/// succeed and then block on read until the guest accepts), the loop returns
/// at once if the VM has exited (a fast-exiting container never stalls), and a
/// large absolute cap is only a last-resort backstop against a wedged-but-alive
/// guest — not the expected wait. Unix keeps its historical best-effort
/// behavior so foreground logs and process exit remain visible. Windows fails
/// startup at the cap because a live WHPX shim without a responsive guest was
/// previously exposed as a false `running` state.
pub(crate) async fn wait_for_exec_ready(
&mut self,
exec_socket_path: &std::path::Path,
) -> Result<()> {
use tokio::time::Duration;
// Per-attempt cap on one heartbeat round-trip. Do not call
// `ExecClient::connect` first: it opens and immediately drops a separate
// stream, which is harmless for a Unix socket but creates an abandoned
// WHPX tunnel just as the guest control channel comes online. Windows gets
// a wider cap for the named-pipe -> control-channel -> guest-socket OPEN
// handshake; Unix keeps the historical quick liveness polling cadence.
#[cfg(unix)]
const ATTEMPT_TIMEOUT: Duration = Duration::from_millis(500);
#[cfg(windows)]
const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(3);
const POLL_INTERVAL: Duration = Duration::from_millis(200);
// Last-resort backstop against a wedged-but-alive guest that binds but
// never accepts. A healthy guest passes the heartbeat the instant its
// accept loop runs, and an exited VM returns immediately below. Keep the
// default short enough that foreground `run` starts streaming the guest's
// logs promptly; callers that truly need a longer cold-boot grace can set
// A3S_EXEC_READY_TIMEOUT_MS.
let max_wait_ms = exec_ready_timeout_ms();
// `exec_socket_path` is the layout/record path shared with Unix. WHPX
// publishes the host side as a named pipe instead, using the box ID.
// CLI commands perform this mapping when they load a record; readiness
// runs before that record becomes `running`, so it must map explicitly.
#[cfg(windows)]
let exec_endpoint =
std::path::PathBuf::from(a3s_box_core::exec::windows_exec_pipe_path(&self.box_id));
#[cfg(not(windows))]
let exec_endpoint = exec_socket_path.to_path_buf();
#[cfg(windows)]
let guest_control_ready_path =
exec_socket_path.with_file_name(a3s_box_core::exec::WINDOWS_GUEST_CONTROL_READY_FILE);
let box_dir = self.home_dir.join("boxes").join(&self.box_id);
tracing::debug!(
socket_path = %exec_endpoint.display(),
timeout_ms = max_wait_ms,
"Waiting for exec server readiness"
);
let start = std::time::Instant::now();
let mut next_progress_log_ms = EXEC_READY_PROGRESS_LOG_MS;
loop {
// Return at once if the VM has already exited (zombie-aware: has_exited
// treats a zombie shim as exited, unlike is_running's kill(pid,0)). A
// fast-exiting container never stalls here.
#[cfg(unix)]
if let Some(exit_code) = self.try_wait_exit().await? {
if crate::rootfs::read_persisted_exit_code(&box_dir).is_some() {
tracing::debug!(exit_code, "Guest completed before exec server became ready");
return Ok(());
}
return Err(vm_exited_before_exec_ready(&box_dir, Some(exit_code)));
}
#[cfg(not(unix))]
if self.try_wait_exit().await?.is_some() {
tracing::debug!("VM exited before exec server became ready");
return Ok(());
}
if let Some(ref handler) = *self.handler.read().await {
if handler.has_exited() {
#[cfg(unix)]
{
if let Some(exit_code) = crate::rootfs::read_persisted_exit_code(&box_dir) {
self.shim_exit_code = Some(exit_code);
tracing::debug!(
exit_code,
"Guest completed before exec server became ready"
);
return Ok(());
}
return Err(vm_exited_before_exec_ready(&box_dir, handler.exit_code()));
}
#[cfg(not(unix))]
{
tracing::debug!("VM exited before exec server became ready");
return Ok(());
}
}
}
// A very short WHPX workload can persist its exact status and close
// the guest-control channel between two 200 ms readiness polls. The
// shim may still be draining logs (or waiting for libkrun to return),
// so `try_wait_exit` deliberately remains pending. Classify this as
// terminal startup completion and let boot cleanup finish the shim
// and collect the durable guest result instead of timing out and
// replacing it with the forced process exit code.
#[cfg(windows)]
if let Some(exit_code) = super::windows_guest_persisted_exit_code(&box_dir) {
tracing::debug!(
exit_code,
"WHPX guest completed before exec server became ready"
);
return Err(BoxError::BoxBootError {
message: format!(
"WHPX guest completed with exit code {exit_code} before the exec server became ready"
),
hint: None,
});
}
// One bounded connect + heartbeat attempt. On Windows, first wait for
// the worker's guest-control marker. Opening the host exec pipe before
// that connection exists would enqueue abandoned sessions in the
// synchronous pipe worker and can delay the very guest boot being
// measured. A timeout or any protocol error just means "retry".
#[cfg(windows)]
let guest_control_ready = guest_control_ready_path.is_file();
#[cfg(not(windows))]
let guest_control_ready = true;
if guest_control_ready {
let client = ExecClient::for_socket(&exec_endpoint);
if let Ok(Ok(true)) =
tokio::time::timeout(ATTEMPT_TIMEOUT, client.heartbeat()).await
{
tracing::debug!("Exec server heartbeat passed");
#[cfg(unix)]
{
self.exec_client = Some(client);
}
return Ok(());
}
}
let elapsed_ms = start.elapsed().as_millis() as u64;
if elapsed_ms >= max_wait_ms {
#[cfg(windows)]
return Err(BoxError::BoxBootError {
message: format!(
"WHPX guest exec server did not become ready within {max_wait_ms} ms"
),
hint: Some(
"The VM shim remained alive but the Windows guest-control/exec channel did not answer; inspect the per-box console and shim logs"
.to_string(),
),
});
#[cfg(not(windows))]
{
tracing::warn!(
timeout_ms = max_wait_ms,
elapsed_ms,
socket_path = %exec_endpoint.display(),
"Exec server did not become ready within the safety cap; proceeding so foreground logs and process exit are visible. Exec/attach will connect on demand once the guest finishes starting."
);
return Ok(());
}
}
if elapsed_ms >= next_progress_log_ms {
tracing::warn!(
elapsed_ms,
timeout_ms = max_wait_ms,
socket_path = %exec_endpoint.display(),
"Still waiting for exec server readiness; guest init may be mounting volumes, starting the container, or blocked before its accept loop"
);
next_progress_log_ms =
next_progress_log_ms.saturating_add(EXEC_READY_PROGRESS_LOG_MS);
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
/// Single best-effort exec-server probe for snapshot-restore boots.
///
/// A restored guest is already past boot, so its exec server never re-signals
/// readiness the way a cold boot does — blocking on [`wait_for_exec_ready`]'s
/// cold-boot loop would stall registration for up to its safety cap. Instead try
/// exactly one connect + heartbeat to populate `exec_client` if the guest answers
/// promptly, and otherwise proceed immediately: exec/attach connect on demand.
#[cfg(unix)]
pub(crate) async fn probe_exec_ready_once(&mut self, exec_socket_path: &std::path::Path) {
use tokio::time::Duration;
const ATTEMPT_TIMEOUT: Duration = Duration::from_millis(500);
let client = ExecClient::for_socket(exec_socket_path);
if let Ok(Ok(true)) = tokio::time::timeout(ATTEMPT_TIMEOUT, client.heartbeat()).await {
tracing::debug!("restore: exec server heartbeat passed");
self.exec_client = Some(client);
return;
}
tracing::debug!(
"restore: exec server did not answer an immediate heartbeat; exec/attach will connect on demand"
);
}
}
#[cfg(unix)]
fn vm_exited_before_exec_ready(box_dir: &std::path::Path, exit_code: Option<i32>) -> BoxError {
let message = match exit_code {
Some(exit_code) => format!(
"VM process exited with code {exit_code} before the guest exec server became ready"
),
None => "VM process exited before the guest exec server became ready".to_string(),
};
let logs = box_dir.join("logs");
BoxError::BoxBootError {
message,
hint: Some(format!(
"Inspect {} and {} for the primary startup failure",
logs.join("shim.stderr.log").display(),
logs.join("console.err.log").display()
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_exec_ready_timeout_ms() {
assert_eq!(
parse_exec_ready_timeout_ms(None),
DEFAULT_EXEC_READY_TIMEOUT_MS
);
assert_eq!(
parse_exec_ready_timeout_ms(Some("0")),
DEFAULT_EXEC_READY_TIMEOUT_MS
);
assert_eq!(
parse_exec_ready_timeout_ms(Some("not-a-number")),
DEFAULT_EXEC_READY_TIMEOUT_MS
);
assert_eq!(parse_exec_ready_timeout_ms(Some("2500")), 2500);
}
}