Skip to content

Commit d8e178c

Browse files
committed
Fix duplicate guest trace batches
The host stamped the TraceBatch magic in r8 on every OUT and polled r8 on every VM exit. r8 is sticky, so the host re-read the same batch on later exits and emitted duplicate spans. Deliver batches on a dedicated TraceBatch port. The host reads guest registers only on that exit, so each batch is read once. Aborts flush the buffer over the same port so the final batch still reaches the host. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com>
1 parent 35f3d45 commit d8e178c

7 files changed

Lines changed: 37 additions & 175 deletions

File tree

docs/hyperlight-metrics-logs-and-traces.md

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -137,27 +137,30 @@ runs `no_std` which `opentelemetry` doesn't know.
137137

138138
##### Guest
139139

140-
When the guest starts executing the `entrypoint` function, it receives a `max_log_level` parameter that tells the guest what kind of logging level is expected from it.
140+
When the guest starts executing the `entrypoint` function, it receives a `max_log_level` parameter that tells the guest what logging level the host expects.
141141

142-
The `trace_guest` logic takes advantage of this parameter and when the `max_log_level` is `trace`, it allocates a custom made `GuestSubscriber` that implements the `Subscriber`
143-
trait from `tracing_core` that allows defining a subscriber for the `tracing` crate to handle new spans and events.
142+
The `trace_guest` logic uses this parameter. When `max_log_level` is not `Off`, the guest installs a custom `GuestSubscriber` that implements the `Subscriber` trait from `tracing_core`, so it receives every span and event created through the `tracing` crate.
144143

145-
This custom subscriber stores the spans and events in a buffer initialized only when tracing is enabled. For each new span and event, a method is called on the custom subscriber which not only stores the data, but also keeps track of the hierarchy and dependencies between the other spans/events.
146-
**NOTE**: The spans/events attributes are truncated to fit in the allocated buffer.
144+
The subscriber writes spans and events into a fixed-size buffer in guest memory. Each record keeps its parent link so the host can rebuild the hierarchy. Every span and event also records the current `TSC` value, which the host later turns into wall-clock timestamps.
147145

148-
When the storage space is filled, the guest triggers a VM Exit that sends the guest pointers to the host. The host can access the guest memory, get the data and parse it to create the `spans` and `events` using the `opentelemetry` crate which allows specifying the starting and ending timestamps
149-
which are captured in the guest using the `TSC`.
150-
151-
To improve performance, for each VMExit, the guest adds metadata for the host to be able to report the tracing data and free space.
146+
The guest delivers a batch by triggering a VM exit on the dedicated `TraceBatch` port. It passes the buffer pointer in `r9`, its length in `r10`, and the `TraceBatch` magic in `r8`. A batch is sent in two cases:
147+
* The buffer fills up.
148+
* The guest calls `flush()`. This happens after guest initialization, at the end of each guest function call, and on a deliberate abort.
152149

153150
##### Host
154151

155-
When a guest exits, the host checks for metadata from the guest reporting tracing data.
156-
If tracing data is found, the host starts parsing it and reconstructing a tree which represents the spans hierarchy.
152+
The host reads a batch only on the VM exit that carries it, the `TraceBatch` port exit. It reads the pointer and length from the guest registers, walks the guest page tables to translate the guest virtual address to a physical address (needed because copy-on-write means guest pages are not identity-mapped), and reads the buffer.
153+
154+
The host decodes the batch and rebuilds the span hierarchy. It creates one `opentelemetry` span or event per guest record, using the `TSC` values to set explicit start and end times. It also inserts a `call-to-host` span for each exit into the host, so a call across the VM boundary shows host work as a child of the active guest span.
155+
156+
A batch that fails to decode is logged and skipped. Trace data is advisory, so the guest keeps running.
157157

158-
Additionally, the host also adds new children `span`s to the guest's reported active span, emphasizing the spans created on the host as a result of a temporary VM Exit. This helps visualize a call into the guest with context propagated across the VM boundary.
158+
#### Limitations
159159

160-
The host creates `opentelemetry` spans and events for each guest span and event reported.
160+
* Batches are delivered only on the `TraceBatch` exit, on buffer overflow or `flush()`. Normal function returns, guest initialization, deliberate aborts, and out-of-memory aborts flush. Panic and exception aborts use a raw abort path that does not flush, so their final buffered batch is lost.
161+
* A record larger than the buffer produces a batch that exceeds the host size limit, so the host drops that whole batch.
162+
* Guest tracing is gated at runtime by `max_log_level` (`Off` disables it) and at compile time by the `trace_guest` feature.
163+
* The host reconstructs guest spans with `opentelemetry`, so enabling `trace_guest` on the host pulls in the `opentelemetry` crates.
161164

162165
### Inspecting Guest memory Trace Files (for mem_profile)
163166

src/hyperlight_guest/src/arch/amd64/exit.rs

Lines changed: 3 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -16,44 +16,10 @@ limitations under the License.
1616

1717
use core::arch::asm;
1818

19-
#[cfg(feature = "trace_guest")]
20-
use hyperlight_common::outb::OutBAction;
21-
22-
/// OUT function for sending a 32-bit value to the host.
23-
/// `out32` can be called from an exception context, so we must be careful
24-
/// with the tracing state that might be locked at that time.
25-
/// The tracing state calls `try_lock` internally to avoid deadlocks.
26-
/// Furthermore, the instrument macro is not used here to avoid creating spans
27-
/// in exception contexts. Because if the trace state is already locked, trying to create a span
28-
/// would cause a panic, which is undesirable in exception handling.
19+
/// OUT function for sending a 32-bit value to the host on the given port.
20+
/// A pure I/O function that issues a single OUT instruction, safe to call
21+
/// from an exception context.
2922
pub(crate) unsafe fn out32(port: u16, val: u32) {
30-
#[cfg(feature = "trace_guest")]
31-
{
32-
if let Some((ptr, len)) = hyperlight_guest_tracing::serialized_data() {
33-
// If tracing is enabled and there is data to send, send it along with the OUT action
34-
unsafe {
35-
asm!("out dx, eax",
36-
in("dx") port,
37-
in("eax") val,
38-
in("r8") OutBAction::TraceBatch as u64,
39-
in("r9") ptr,
40-
in("r10") len,
41-
options(preserves_flags, nostack)
42-
)
43-
};
44-
45-
// Reset the trace state after sending the batch
46-
// This clears all existing spans/events ensuring a clean state for the next operations
47-
// The trace state is expected to be flushed before this call
48-
hyperlight_guest_tracing::reset();
49-
} else {
50-
// If tracing is not enabled, just send the value
51-
unsafe {
52-
asm!("out dx, eax", in("dx") port, in("eax") val, options(preserves_flags, nostack))
53-
};
54-
}
55-
}
56-
#[cfg(not(feature = "trace_guest"))]
5723
unsafe {
5824
asm!("out dx, eax", in("dx") port, in("eax") val, options(preserves_flags, nostack));
5925
}

src/hyperlight_guest/src/exit.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ pub extern "C" fn abort() -> ! {
3131

3232
/// Exits the VM with an Abort OUT action and a specific code.
3333
pub fn abort_with_code(code: &[u8]) -> ! {
34-
// End any ongoing trace before aborting
34+
// Send the buffered trace batch to the host before aborting. `flush`
35+
// closes open spans, which allocates. An abort caused by allocation
36+
// failure may lose the batch here.
3537
#[cfg(all(feature = "trace_guest", target_arch = "x86_64"))]
36-
hyperlight_guest_tracing::end_trace();
38+
hyperlight_guest_tracing::flush();
3739
outb(OutBAction::Abort as u16, code);
3840
outb(OutBAction::Abort as u16, &[0xFF]); // send abort terminator (if not included in code)
3941
unreachable!()
@@ -44,9 +46,11 @@ pub fn abort_with_code(code: &[u8]) -> ! {
4446
/// # Safety
4547
/// This function is unsafe because it dereferences a raw pointer.
4648
pub unsafe fn abort_with_code_and_message(code: &[u8], message_ptr: *const c_char) -> ! {
47-
// End any ongoing trace before aborting
49+
// Send the buffered trace batch to the host before aborting. `flush`
50+
// closes open spans, which allocates. An abort caused by allocation
51+
// failure may lose the batch here.
4852
#[cfg(all(feature = "trace_guest", target_arch = "x86_64"))]
49-
hyperlight_guest_tracing::end_trace();
53+
hyperlight_guest_tracing::flush();
5054
unsafe {
5155
// Step 1: Send abort code (typically 1 byte, but `code` allows flexibility)
5256
outb(OutBAction::Abort as u16, code);
@@ -75,7 +79,6 @@ pub fn write_abort(code: &[u8]) {
7579

7680
/// OUT bytes to the host through multiple exits.
7781
pub(crate) fn outb(port: u16, data: &[u8]) {
78-
// Ensure all tracing data is flushed before sending OUT bytes
7982
unsafe {
8083
let mut i = 0;
8184
while i < data.len() {

src/hyperlight_guest_tracing/src/lib.rs

Lines changed: 1 addition & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,8 @@ mod subscriber;
3232
#[cfg(feature = "trace")]
3333
mod visitor;
3434

35-
/// Type to get the relevant information from the internal state
36-
/// and expose it to the host
3735
#[cfg(feature = "trace")]
38-
pub use state::TraceBatchInfo;
39-
#[cfg(feature = "trace")]
40-
pub use trace::{
41-
end_trace, flush, init_guest_tracing, is_trace_enabled, new_call, reset, serialized_data,
42-
};
36+
pub use trace::{flush, init_guest_tracing, is_trace_enabled, new_call};
4337

4438
/// This module is gated because some of these types are also used on the host, but we want
4539
/// only the guest to allocate and allow the functionality intended for the guest.
@@ -77,36 +71,6 @@ mod trace {
7771
let _ = tracing_core::dispatcher::set_global_default(tracing_core::Dispatch::new(sub));
7872
}
7973

80-
/// Ends the current trace by ending all active spans in the
81-
/// internal state and storing the end timestamps.
82-
///
83-
/// This expects an outb call to send the spans to the host.
84-
/// After calling this function, the internal state is marked
85-
/// for cleaning on the next access.
86-
///
87-
/// NOTE: Panics if unable to lock the guest state.
88-
pub fn end_trace() {
89-
if let Some(w) = GUEST_STATE.get()
90-
&& let Some(state_mutex) = w.upgrade()
91-
{
92-
// We want to protect against re-entrancy issues produced by tracing code that locks
93-
// the state and then causes an exception that tries to lock the state again.
94-
//
95-
// For example:
96-
// - 1. A span is created, locking the state
97-
// - 2. An exception occurs while the span is being created (e.g. not enough memory, etc.)
98-
// - 3. The exception handler uses the tracing API to send the trace data to the host
99-
// or just create spans/events for logging purposes.
100-
// - 4. The tracing API tries to lock the state again, causing a deadlock.
101-
// To avoid this, we use try_lock and if we cannot acquire the lock, we panic to signal
102-
// the issue.
103-
let mut state = state_mutex
104-
.try_lock()
105-
.expect("guest_tracing: Unable to lock guest tracing state in `end_trace`");
106-
state.end_trace();
107-
}
108-
}
109-
11074
/// Flushes the current trace data to prepare it for reading by the host.
11175
/// NOTE: Panics if unable to lock the guest state.
11276
pub fn flush() {
@@ -158,59 +122,6 @@ mod trace {
158122
}
159123
}
160124

161-
/// Cleans the internal trace state by removing closed spans and events.
162-
/// This ensures that after a VM exit, we keep the spans that
163-
/// are still active (in the stack) and remove all other spans and events.
164-
/// NOTE: Panics if unable to lock the guest state.
165-
pub fn reset() {
166-
if let Some(w) = GUEST_STATE.get()
167-
&& let Some(state_mutex) = w.upgrade()
168-
{
169-
// We want to protect against re-entrancy issues produced by tracing code that locks
170-
// the state and then causes an exception that tries to lock the state again.
171-
//
172-
// For example:
173-
// - 1. A span is created, locking the state
174-
// - 2. An exception occurs while the span is being created (e.g. not enough memory, etc.)
175-
// - 3. The exception handler uses the tracing API to send the trace data to the host
176-
// or just create spans/events for logging purposes.
177-
// - 4. The tracing API tries to lock the state again, causing a deadlock.
178-
// To avoid this, we use try_lock and if we cannot acquire the lock, we panic to signal
179-
// the issue.
180-
let mut state = state_mutex
181-
.try_lock()
182-
.expect("Unable to lock GuestState in `reset`");
183-
184-
state.reset();
185-
}
186-
}
187-
188-
/// Returns information about the current trace state needed by the host to read the spans.
189-
pub fn serialized_data() -> Option<(u64, u64)> {
190-
if let Some(w) = GUEST_STATE.get()
191-
&& let Some(state_mutex) = w.upgrade()
192-
{
193-
// We want to protect against re-entrancy issues produced by tracing code that locks
194-
// the state and then causes an exception that tries to lock the state again.
195-
//
196-
// For example:
197-
// - 1. A span is created, locking the state
198-
// - 2. An exception occurs while the span is being created (e.g. not enough memory, etc.)
199-
// - 3. The exception handler uses the tracing API to send the trace data to the host
200-
// or just create spans/events for logging purposes.
201-
// - 4. The tracing API tries to lock the state again, causing a deadlock.
202-
// To avoid this, we use try_lock and if we cannot acquire the lock, we panic to signal
203-
// the issue.
204-
let state = state_mutex
205-
.try_lock()
206-
.expect("Unable to lock GuestState in `serialized_data`");
207-
208-
state.serialized_data()
209-
} else {
210-
None
211-
}
212-
}
213-
214125
/// Returns true if tracing is enabled (the guest tracing state is initialized).
215126
pub fn is_trace_enabled() -> bool {
216127
GUEST_STATE

src/hyperlight_guest_tracing/src/state.rs

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,6 @@ use tracing_core::span::{Attributes, Id, Record};
2929
use crate::invariant_tsc;
3030
use crate::visitor::FieldsVisitor;
3131

32-
pub struct TraceBatchInfo {
33-
pub serialized_data: Vec<u8>,
34-
}
35-
3632
/// Internal state of the tracing subscriber
3733
pub(crate) struct GuestState {
3834
/// Encoder for events
@@ -57,6 +53,7 @@ fn send_to_host(data: &[u8]) {
5753
in("r8") OutBAction::TraceBatch as u64,
5854
in("r9") data.as_ptr() as u64,
5955
in("r10") data.len() as u64,
56+
options(nostack, preserves_flags),
6057
);
6158
}
6259
}
@@ -104,12 +101,6 @@ impl GuestState {
104101
.encode(&GuestEvent::GuestStart { tsc: start_tsc });
105102
}
106103

107-
/// Reset the trace state, clearing all existing spans and events
108-
/// This is called after the trace has been flushed to the host
109-
pub(crate) fn reset(&mut self) {
110-
self.encoder.reset();
111-
}
112-
113104
/// Closes the trace by ending all spans
114105
/// NOTE: This expects an outb call to send the spans to the host.
115106
pub(crate) fn end_trace(&mut self) {
@@ -126,17 +117,6 @@ impl GuestState {
126117
}
127118
}
128119

129-
/// Return (ptr, len) for serialized data if any is available
130-
pub(crate) fn serialized_data(&self) -> Option<(u64, u64)> {
131-
let data = self.encoder.finish();
132-
133-
if data.is_empty() {
134-
None
135-
} else {
136-
Some((data.as_ptr() as u64, data.len() as u64))
137-
}
138-
}
139-
140120
/// Create a new span and push it on the stack
141121
pub(crate) fn new_span(&mut self, attrs: &Attributes) -> Id {
142122
let (idn, id) = self.alloc_id();

src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -622,11 +622,15 @@ impl HyperlightVm {
622622
#[cfg(feature = "trace_guest")]
623623
{
624624
tc.end_host_trace();
625-
// Handle the guest trace data if any
626-
let regs = self.vm.regs().map_err(RunVmError::GetRegs)?;
627625

628-
// Only parse the trace if it has reported
629-
if tc.has_trace_data(&regs) {
626+
// Guest trace batches arrive on the dedicated `TraceBatch`
627+
// port. Read the batch only on the exit that carries it. A
628+
// parse failure is logged and execution continues, since
629+
// trace data is advisory to the guest.
630+
if let Ok(VmExit::IoOut(port, _)) = &result
631+
&& *port == hyperlight_common::outb::OutBAction::TraceBatch as u16
632+
{
633+
let regs = self.vm.regs().map_err(RunVmError::GetRegs)?;
630634
let root_pt = self.get_root_pt().map_err(RunVmError::PageTableAccess)?;
631635

632636
// If something goes wrong with parsing the trace data, we log the error and

src/hyperlight_host/src/sandbox/trace/context.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,11 +198,6 @@ impl TraceContext {
198198
+ Duration::from_micros(rel_start_us as u64))
199199
}
200200

201-
/// Check if the registers indicate that there is trace data to be handled.
202-
pub fn has_trace_data(&self, regs: &CommonRegisters) -> bool {
203-
regs.r8 == OutBAction::TraceBatch as u64
204-
}
205-
206201
pub fn handle_trace(
207202
&mut self,
208203
regs: &CommonRegisters,

0 commit comments

Comments
 (0)