Skip to content

Commit 5e05328

Browse files
milankinenclaude
andcommitted
Push host wall-clock to guest every minute to correct drift
VMs have no RTC. The guest clock is set once in Supervisor.start(), so long host sleeps (laptop lid, suspend) leave the guest's CLOCK_REALTIME frozen for the sleep duration — breaking TLS cert validation and every mtime-driven build tool after resume. Add Supervisor.syncClock(epoch, epochNanos) — an idempotent, cheap RPC that just re-applies clock_settime. The host ticks it every 60 s from cmd_start after Supervisor.start() returns. Errors are logged at debug since transient RPC failures aren't alarming. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0b9e532 commit 5e05328

7 files changed

Lines changed: 71 additions & 1 deletion

File tree

app/airlock-cli/src/cli/cmd_start.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,12 @@ async fn run(
216216
.await?;
217217
info!("vm process started");
218218

219+
// Push the host wall-clock into the guest every minute so the VM
220+
// clock stays in sync across host sleeps (laptop lid closed,
221+
// suspend). VMs have no RTC, so without this the guest time
222+
// drifts by exactly the sleep duration.
223+
supervisor.spawn_clock_sync(std::time::Duration::from_secs(60));
224+
219225
// Start CLI server so `airlock exec` can attach processes to this VM.
220226
// The server needs a copy of the sandbox's resolved env so it can layer
221227
// `airlock exec -e KEY=VAL` overrides on top without the exec client

app/airlock-cli/src/rpc/supervisor.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,35 @@ impl Supervisor {
106106
self.supervisor.clone()
107107
}
108108

109+
/// Spawn a background task that pushes the host wall-clock into the
110+
/// guest every `interval`. VMs have no RTC, so long host sleeps
111+
/// (laptop lid closed, suspend) cause the guest clock to drift —
112+
/// breaking TLS validation and every `mtime`-driven build tool.
113+
/// The RPC is cheap (one UInt64 + UInt32 round-trip) and idempotent;
114+
/// we just keep re-setting the guest clock to the current host
115+
/// value.
116+
pub fn spawn_clock_sync(&self, interval: std::time::Duration) {
117+
let supervisor = self.supervisor.clone();
118+
tokio::task::spawn_local(async move {
119+
let mut ticker = tokio::time::interval(interval);
120+
// Skip the immediate first tick — the guest's clock was
121+
// just set by `Supervisor.start`.
122+
ticker.tick().await;
123+
loop {
124+
ticker.tick().await;
125+
let now = std::time::SystemTime::now()
126+
.duration_since(std::time::UNIX_EPOCH)
127+
.unwrap_or_default();
128+
let mut req = supervisor.sync_clock_request();
129+
req.get().set_epoch(now.as_secs());
130+
req.get().set_epoch_nanos(now.subsec_nanos());
131+
if let Err(e) = req.send().promise.await {
132+
tracing::debug!("clock sync: {e}");
133+
}
134+
}
135+
});
136+
}
137+
109138
/// Send the initial `Supervisor.start()` RPC to bootstrap the VM and
110139
/// launch the main container process. Returns a [`Process`] handle for
111140
/// polling output and forwarding signals.

app/airlock-common/schema/supervisor.capnp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ interface Supervisor {
8181
# every still-running daemon. Host follows up with `pollDaemons` until
8282
# all daemons reach a terminal state (`stopped` or `killed`).
8383
shutdownDaemons @7 () -> ();
84+
85+
# Reapply the host wall-clock to the guest. VMs have no RTC; the
86+
# initial clock is set in `start @0`, but long host sleeps (laptop
87+
# lid closed) cause the guest time to drift. The host polls this
88+
# every few seconds to keep them within wake-up-jitter of each other.
89+
syncClock @8 (epoch :UInt64, epochNanos :UInt32) -> ();
8490
}
8591

8692
struct DaemonSpec {

app/airlockd/src/init.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,14 @@ pub fn setup(
8383
) -> anyhow::Result<()> {
8484
unimplemented!("supervisor only runs inside the Linux VM");
8585
}
86+
87+
/// Reapply the host wall-clock to the guest. Idempotent; called at
88+
/// startup via `setup` and periodically thereafter via the
89+
/// `Supervisor.syncClock` RPC to correct drift after host sleeps.
90+
#[cfg(target_os = "linux")]
91+
pub fn set_clock(epoch: u64, epoch_nanos: u32) {
92+
linux::set_clock(epoch, epoch_nanos);
93+
}
94+
95+
#[cfg(not(target_os = "linux"))]
96+
pub fn set_clock(_epoch: u64, _epoch_nanos: u32) {}

app/airlockd/src/init/linux.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ mod mount;
1919
mod net;
2020
mod overlay;
2121

22+
/// Apply the host wall-clock to `CLOCK_REALTIME`. See
23+
/// [`super::set_clock`].
24+
pub(super) fn set_clock(epoch: u64, epoch_nanos: u32) {
25+
clock::set(epoch, epoch_nanos);
26+
}
27+
2228
/// Run all guest initialization steps in order, including container mounts.
2329
pub fn setup(
2430
config: &InitConfig,

app/airlockd/src/rpc.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,4 +422,14 @@ impl supervisor::Server for SupervisorImpl {
422422
}
423423
Ok(())
424424
}
425+
426+
async fn sync_clock(
427+
self: Rc<Self>,
428+
params: supervisor::SyncClockParams,
429+
_results: supervisor::SyncClockResults,
430+
) -> Result<(), capnp::Error> {
431+
let params = params.get()?;
432+
crate::init::set_clock(params.get_epoch(), params.get_epoch_nanos());
433+
Ok(())
434+
}
425435
}

docs/manual/src/technical/vm-init.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ mounts win over earlier dir bind mounts.
1616

1717
1. **Clock** (`clock::set`) — the host passes Unix epoch + nanos in
1818
the `start` RPC; the guest sets the system clock so timestamps are
19-
correct from the start.
19+
correct from the start. The host re-pushes the wall-clock every
20+
minute via `Supervisor.syncClock` to correct drift after host
21+
sleeps (VMs have no RTC).
2022

2123
2. **VirtioFS shares** (`mount::virtiofs`):
2224
- `layers` — shared per-layer OCI cache (read-only).

0 commit comments

Comments
 (0)