Skip to content

Commit 2910a2b

Browse files
authored
Support core dump on snapshots created from snapshot/disk (#1618)
* Rename entrypoint field to next_action Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> * Persist guest ELF entry point for crashdump AT_ENTRY Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> * Test crashdump symbol resolution for snapshot sandboxes Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> * Update changelog Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> * Update snapshot-versioning.md Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --------- Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com>
1 parent e9b1c85 commit 2910a2b

13 files changed

Lines changed: 358 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1111
### Removed
1212

1313
### Fixed
14+
* Fix symbol resolution in guest core dumps for sandboxes created from snapshots by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/1618
1415

1516
## [v0.16.0] - 2026-06-26
1617

docs/snapshot-versioning.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@ The config blob also records `hyperlight_version`, the `CARGO_PKG_VERSION`
4444
of the host crate at write time. This is informational only. The loader
4545
records it for diagnostics and does not gate loading on it.
4646

47+
## Compatibility cleanup
48+
49+
Record compatibility paths here when a future hard snapshot break can remove
50+
them.
51+
52+
### Original ELF entry point
53+
54+
The persisted `original_entrypoint_addr` field defaults to zero so snapshots
55+
made before it was added remain loadable. At the next hard break, make the
56+
field required, remove `serde(default)`, and reject zero as an invalid entry
57+
point rather than treating it as unknown.
58+
4759
## Enforcement
4860

4961
The format is large and easy to change by accident. Two mechanisms

src/hyperlight_host/examples/crashdump/main.rs

Lines changed: 192 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ mod tests {
342342
use std::process::Command;
343343

344344
use hyperlight_host::sandbox::SandboxConfiguration;
345-
use hyperlight_host::{GuestBinary, MultiUseSandbox, UninitializedSandbox};
345+
use hyperlight_host::{GuestBinary, HostFunctions, MultiUseSandbox, UninitializedSandbox};
346346
use serial_test::serial;
347347

348348
#[cfg(not(windows))]
@@ -431,8 +431,9 @@ mod tests {
431431
sbox.generate_crashdump_to_dir(dump_dir.to_string_lossy())
432432
.expect("generate_crashdump should succeed");
433433

434-
// Find the generated hl_core_*.elf file
435-
let mut elf_files: Vec<PathBuf> = fs::read_dir(dump_dir)
434+
// Find the generated hl_core_*.elf file. The dump dir is a fresh
435+
// per-test tempdir, so exactly one core must be present.
436+
let elf_files: Vec<PathBuf> = fs::read_dir(dump_dir)
436437
.unwrap()
437438
.filter_map(|e| e.ok())
438439
.map(|e| e.path())
@@ -443,15 +444,161 @@ mod tests {
443444
})
444445
.collect();
445446

447+
assert_eq!(
448+
elf_files.len(),
449+
1,
450+
"Expected exactly one core dump file (hl_core_*.elf) in {}, found {}",
451+
dump_dir.display(),
452+
elf_files.len()
453+
);
454+
455+
elf_files.into_iter().next().unwrap()
456+
}
457+
458+
/// Snapshot an initialized sandbox, build a fresh sandbox from that
459+
/// snapshot, trigger a crash on it, and return the path to the generated
460+
/// ELF core dump. Used to check that crash dumps from snapshot-created
461+
/// sandboxes resolve symbols the same way as directly-evolved ones.
462+
fn generate_crashdump_from_snapshot(dump_dir: &Path) -> PathBuf {
463+
let guest_path =
464+
hyperlight_testing::simple_guest_as_string().expect("Cannot find simpleguest binary");
465+
let mut cfg = SandboxConfiguration::default();
466+
cfg.set_guest_core_dump(true);
467+
let u_sbox =
468+
UninitializedSandbox::new(GuestBinary::FilePath(guest_path), Some(cfg)).unwrap();
469+
let mut sbox: MultiUseSandbox = u_sbox.evolve().unwrap();
470+
471+
let snapshot = sbox.snapshot().expect("snapshot");
472+
473+
let mut cfg2 = SandboxConfiguration::default();
474+
cfg2.set_guest_core_dump(true);
475+
let mut sbox2 =
476+
MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), Some(cfg2)).unwrap();
477+
478+
let result = sbox2.call::<()>("TriggerException", ());
479+
assert!(result.is_err(), "TriggerException should return an error");
480+
481+
sbox2
482+
.generate_crashdump_to_dir(dump_dir.to_string_lossy())
483+
.expect("generate_crashdump should succeed");
484+
485+
// The dump dir is a fresh per-test tempdir, so exactly one core
486+
// must be present.
487+
let elf_files: Vec<PathBuf> = fs::read_dir(dump_dir)
488+
.unwrap()
489+
.filter_map(|e| e.ok())
490+
.map(|e| e.path())
491+
.filter(|p| {
492+
p.file_name()
493+
.and_then(|n| n.to_str())
494+
.map_or(false, |n| n.starts_with("hl_core_") && n.ends_with(".elf"))
495+
})
496+
.collect();
497+
498+
assert_eq!(
499+
elf_files.len(),
500+
1,
501+
"Expected exactly one core dump file (hl_core_*.elf) in {}, found {}",
502+
dump_dir.display(),
503+
elf_files.len()
504+
);
505+
506+
elf_files.into_iter().next().unwrap()
507+
}
508+
509+
/// Load `core_path` in GDB against the guest binary and assert that
510+
/// `info symbol $pc` resolves to the guest function that raised the abort.
511+
/// Resolving to the correct symbol only works when the core's `AT_ENTRY`
512+
/// conveys the right PIE load bias. A wrong bias still resolves `$pc` to
513+
/// *some* symbol, just the wrong one, so the expected name is asserted.
514+
///
515+
/// As an independent check on the underlying mechanism, this also reads
516+
/// the auxiliary vector (`info auxv`) and asserts the `AT_ENTRY` entry is
517+
/// present and non-zero. GDB derives the PIE load bias from `AT_ENTRY`, so
518+
/// a zero (or missing) value is the exact defect a broken entry point
519+
/// produces, independent of GDB's symbol-relocation heuristics.
520+
fn assert_gdb_resolves_pc_symbol(dump_dir: &Path, core_path: &Path) {
521+
// The crash path is deterministic: TriggerException raises a CPU
522+
// exception, and the guest handler reports it to the host via the
523+
// abort/`outb` path, so `$pc` sits in that path when the dump is
524+
// taken. Which exact leaf `$pc` lands on depends on inlining
525+
// (`hyperlight_guest::exit::write_abort` when inlined, the
526+
// `hyperlight_guest::exit::arch::out32` leaf in a non-inlined debug
527+
// build), so we assert on the shared `hyperlight_guest::exit::`
528+
// module prefix rather than one specific function.
529+
const EXPECTED_SYMBOL: &str = "hyperlight_guest::exit::";
530+
531+
let guest_path = hyperlight_testing::simple_guest_as_string().expect("simpleguest binary");
532+
533+
let cmd_file = dump_dir.join("gdb_sym_cmds.txt");
534+
let out_file = dump_dir.join("gdb_sym_output.txt");
535+
536+
let cmds = format!(
537+
"\
538+
set pagination off
539+
set logging file {out}
540+
set logging enabled on
541+
file {binary}
542+
core-file {core}
543+
echo === SYMBOL ===\\n
544+
info symbol $pc
545+
echo === AUXV ===\\n
546+
info auxv
547+
echo === DONE ===\\n
548+
set logging enabled off
549+
quit
550+
",
551+
out = out_file.display(),
552+
binary = guest_path,
553+
core = core_path.display(),
554+
);
555+
556+
let gdb_output = run_gdb_batch(&cmd_file, &out_file, &cmds);
557+
println!("GDB symbol output:\n{gdb_output}");
558+
559+
assert!(
560+
gdb_output.contains("=== SYMBOL ==="),
561+
"GDB should have printed the SYMBOL marker.\nOutput:\n{gdb_output}"
562+
);
563+
assert!(
564+
!gdb_output.contains("No symbol matches $pc"),
565+
"GDB failed to resolve $pc to a symbol — this indicates the core's \
566+
AT_ENTRY does not convey the correct PIE load bias.\nOutput:\n{gdb_output}"
567+
);
568+
assert!(
569+
gdb_output.contains(EXPECTED_SYMBOL),
570+
"GDB resolved $pc to the wrong symbol — the core's AT_ENTRY conveys \
571+
an incorrect PIE load bias. Expected `{EXPECTED_SYMBOL}`.\nOutput:\n{gdb_output}"
572+
);
573+
574+
// Independent mechanism check: AT_ENTRY must be present and non-zero.
575+
// `info auxv` prints one entry per line, e.g.
576+
// `9 AT_ENTRY Entry point of program 0x200000da0`
577+
// The value is the last whitespace-separated token on the line.
578+
let at_entry = gdb_output
579+
.lines()
580+
.find(|l| l.contains("AT_ENTRY"))
581+
.unwrap_or_else(|| panic!("info auxv did not report AT_ENTRY.\nOutput:\n{gdb_output}"));
582+
let value_tok = at_entry
583+
.split_whitespace()
584+
.next_back()
585+
.expect("AT_ENTRY line has a value token");
586+
let value = value_tok
587+
.strip_prefix("0x")
588+
.and_then(|h| u64::from_str_radix(h, 16).ok())
589+
.unwrap_or_else(|| {
590+
panic!("could not parse AT_ENTRY value {value_tok:?}.\nOutput:\n{gdb_output}")
591+
});
446592
assert!(
447-
!elf_files.is_empty(),
448-
"No core dump file (hl_core_*.elf) found in {}",
449-
dump_dir.display()
593+
value != 0,
594+
"AT_ENTRY is zero — the core does not convey the guest's entry \
595+
point, so GDB cannot compute the PIE load bias.\nOutput:\n{gdb_output}"
450596
);
451597

452-
// Return the newest one (lexicographic sort by timestamp works)
453-
elf_files.sort();
454-
elf_files.pop().unwrap()
598+
assert!(
599+
gdb_output.contains("=== DONE ==="),
600+
"GDB should have completed successfully.\nOutput:\n{gdb_output}"
601+
);
455602
}
456603

457604
/// Write GDB batch commands to `cmd_path`, run GDB, and return the
@@ -591,4 +738,40 @@ quit
591738
"GDB should have completed successfully.\nOutput:\n{gdb_output}"
592739
);
593740
}
741+
742+
/// Verify that GDB can resolve a guest symbol from the crash dump.
743+
///
744+
/// Symbol resolution for the PIE guest binary only works if the core's
745+
/// `AT_ENTRY` auxv entry conveys the correct load bias: GDB computes the
746+
/// bias as `AT_ENTRY - e_entry` and applies it to the binary's symbols.
747+
/// If `AT_ENTRY` is wrong (e.g. zero), GDB cannot match `$pc` to any
748+
/// symbol, so this test guards that the entry point is reported correctly.
749+
#[test]
750+
#[serial]
751+
fn test_crashdump_gdb_symbols() {
752+
if !gdb_is_available() {
753+
eprintln!("Skipping test: {GDB_COMMAND} not found on PATH");
754+
return;
755+
}
756+
757+
let dump_dir = tempfile::tempdir().expect("create temp dir");
758+
let core_path = generate_crashdump_with_content(dump_dir.path());
759+
assert_gdb_resolves_pc_symbol(dump_dir.path(), &core_path);
760+
}
761+
762+
/// Same symbol-resolution guarantee as [`test_crashdump_gdb_symbols`], but
763+
/// for a sandbox created from a snapshot. The crash dump must convey the
764+
/// same `AT_ENTRY` so symbols resolve identically.
765+
#[test]
766+
#[serial]
767+
fn test_crashdump_gdb_symbols_from_snapshot() {
768+
if !gdb_is_available() {
769+
eprintln!("Skipping test: {GDB_COMMAND} not found on PATH");
770+
return;
771+
}
772+
773+
let dump_dir = tempfile::tempdir().expect("create temp dir");
774+
let core_path = generate_crashdump_from_snapshot(dump_dir.path());
775+
assert_gdb_resolves_pc_symbol(dump_dir.path(), &core_path);
776+
}
594777
}

src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ impl HyperlightVm {
5151
snapshot_mem: SnapshotSharedMemory<GuestSharedMemory>,
5252
scratch_mem: GuestSharedMemory,
5353
root_pt_addr: u64,
54-
entrypoint: NextAction,
54+
next_action: NextAction,
5555
rsp_gva: u64,
5656
page_size: usize,
5757
config: &SandboxConfiguration,
@@ -84,7 +84,7 @@ impl HyperlightVm {
8484
let vm_can_reset_vcpu = vm.can_reset_vcpu();
8585
let mut ret = Self {
8686
vm,
87-
entrypoint,
87+
next_action,
8888
rsp_gva,
8989
interrupt_handle,
9090
page_size,
@@ -119,7 +119,7 @@ impl HyperlightVm {
119119
std::sync::Mutex<SandboxMemoryManager<HostSharedMemory>>,
120120
>,
121121
) -> Result<(), InitializeError> {
122-
let NextAction::Initialise(initialise) = self.entrypoint else {
122+
let NextAction::Initialise(initialise) = self.next_action else {
123123
return Ok(());
124124
};
125125
let mut x: [u64; 31] = [0; 31];
@@ -149,7 +149,7 @@ impl HyperlightVm {
149149
return Err(InitializeError::InvalidStackPointer(regs.sp));
150150
}
151151
self.rsp_gva = regs.sp;
152-
self.entrypoint = NextAction::Call(regs.x[0]);
152+
self.next_action = NextAction::Call(regs.x[0]);
153153

154154
Ok(())
155155
}
@@ -162,7 +162,7 @@ impl HyperlightVm {
162162
std::sync::Mutex<SandboxMemoryManager<HostSharedMemory>>,
163163
>,
164164
) -> Result<(), DispatchGuestCallError> {
165-
let NextAction::Call(dispatch_func_addr) = self.entrypoint else {
165+
let NextAction::Call(dispatch_func_addr) = self.next_action else {
166166
return Err(DispatchGuestCallError::Uninitialized);
167167
};
168168
let mut regs = CommonRegisters {

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ pub(crate) struct HyperlightVm {
372372
#[cfg(not(gdb))]
373373
pub(super) vm: Box<dyn VirtualMachine>,
374374
pub(super) page_size: usize,
375-
pub(super) entrypoint: NextAction, // only present if this vm has not yet been initialised
375+
pub(super) next_action: NextAction, // `Initialise` before the guest has run, `Call` afterwards
376376
pub(super) rsp_gva: u64,
377377
pub(super) interrupt_handle: Arc<dyn InterruptHandleImpl>,
378378

@@ -565,14 +565,21 @@ impl HyperlightVm {
565565
self.rsp_gva = gva;
566566
}
567567

568-
/// Get the current entrypoint action
569-
pub(crate) fn get_entrypoint(&self) -> NextAction {
570-
self.entrypoint
568+
/// Get the next action to perform when the sandbox resumes
569+
pub(crate) fn get_next_action(&self) -> NextAction {
570+
self.next_action
571571
}
572572

573-
/// Set the current entrypoint action
574-
pub(crate) fn set_entrypoint(&mut self, entrypoint: NextAction) {
575-
self.entrypoint = entrypoint
573+
/// Set the next action to perform when the sandbox resumes
574+
pub(crate) fn set_next_action(&mut self, next_action: NextAction) {
575+
self.next_action = next_action
576+
}
577+
578+
/// Set the guest ELF entry point used to fill `AT_ENTRY` in
579+
/// crashdumps.
580+
#[cfg(crashdump)]
581+
pub(crate) fn set_crashdump_entry_point(&mut self, entry_point: u64) {
582+
self.rt_cfg.entry_point = Some(entry_point);
576583
}
577584

578585
pub(crate) fn interrupt_handle(&self) -> Arc<dyn InterruptHandle> {

0 commit comments

Comments
 (0)