After free(old_base), if allocate(new_base) fails the allocator
treats old_base as free even though the MMIO/PIO bus still maps the
device there. Subsequent allocations pick old_base, mmio_bus.insert
hits the live mapping and returns Overlap.
Restore old_base on the failure path in both the Memory*BitRegion
and IoRegion branches before bubbling the error up.
PR #7950 added restore_bar_addr() so the BAR config register stays
consistent on failed move_bar(); this completes the same picture
for the allocator side.
Signed-off-by: CMGS <ilskdw@gmail.com>
resume() mirrors pause() for backend communication: it skips the
vhost-user backend call when the device is already disconnected, and it
marks newly failed resume_vhost_user() calls disconnected only when the
classifier identifies transport loss.
Signed-off-by: Dylan Reid <dgreid@fb.com>
pause() returns DeviceDisconnected without calling into the backend when
VhostUserCommon already knows the socket is gone. DeviceManager treats
only that sentinel as log-and-continue, so one dead vhost-user device
does not abort the whole pause iteration.
Signed-off-by: Dylan Reid <dgreid@fb.com>
User-defined zones may be mapped private. Create a memfd for private
zones so that fallocate operations are available on all regions, not
just shared ones. This prepares for zone management via hole punching.
The MAP_ANONYMOUS flag is now omitted since the memory becomes
tmpfs-backed via memfd.
Signed-off-by: JP Kobryn <inwardvessel@gmail.com>
Add common PCI device configuration to the virtio-console device
configuration. This allows setting the device ID (the name), ID, the
PCI segment, and the PCI device ID (BDF), which were previously not
configurable for the virtio-console device.
This gives management software, such as libvirt, more control over PCI
resource assignment and aligns virtio-console with other devices that
already support this functionality [0].
[0] https://github.com/cloud-hypervisor/cloud-hypervisor/issues/8175
On-behalf-of: Philipp Schuster <philipp.schuster@sap.com>
Signed-off-by: Philipp Schuster <philipp.schuster@cyberus-technology.de>
`--serial` still accepted `iommu=on|off` from the shared serial/console
configuration shape, even though only virtio-console has a meaningful
IOMMU connection.
Cloud Hypervisor wires virtio-iommu support through DMA-capable
virtio/VFIO PCI endpoints. The serial devices are legacy UARTs accessed
through PIO or MMIO registers, so exposing an IOMMU option there is
misleading.
On-behalf-of: Philipp Schuster@sap.com
Signed-off-by: Philipp Schuster <philipp.schuster@cyberus-technology.de>
This commit introduces a new struct `CommonConsoleConfig` which is the
base for the split into `ConsoleConfig` and `SerialConfig`. This is a
pre-requisite for allowing more configurable PCI options for the
virtio-console device.
The commit doesn't change or add any functionality.
On-behalf-of: Philipp Schuster@sap.com
Signed-off-by: Philipp Schuster <philipp.schuster@cyberus-technology.de>
The PciDeviceHandle::VfioUser arm in eject_device propagated dma_unmap
failures with ?, which short-circuited the subsequent
remove_dma_mapping_handler loop and left stale Arc<VfioUserDmaMapping>
entries in every virtio-mem device's handler map. Log unmap errors
with warn! and continue so the handler map cleanup always runs.
This only happens if a vfio-user process crashes and the same device is
later removed. However, given we've seen similar issues on vhost-user
this is probably worth cleaning up.
Signed-off-by: Dylan Reid <dgreid@fb.com>
The BusDevice read and write arms for B0EJ_FIELD_OFFSET and
PSEG_FIELD_OFFSET opened with assert!/assert_eq! on data.len(), so a
guest 1/2/8-byte MMIO access to either register panicked the vCPU
thread. Replace each assert with a warn! and early return so unusual
access widths are logged and ignored instead of crashing the VMM.
Signed-off-by: Dylan Reid <dgreid@fb.com>
self.balloon and self.virtio_mem_devices are not updated when
eject_device removes the underlying device, leaving stale
Arc<Mutex<...>> entries that resize_balloon / balloon_size and the
virtio-mem DMA-handler iteration would dereference if reached after
eject. Clear self.balloon and retain-out the matching virtio-mem entry
in the PciDeviceHandle::Virtio eject arm, identifying the ejected
device by Arc pointer-equality against the already-resolved
Arc<Mutex<dyn VirtioDevice>>.
This change is defensive: DeviceManager::remove_device currently
rejects VirtioDeviceType::Balloon and VirtioDeviceType::Mem with
RemovalNotAllowed before pci_devices_down is set, so the guest never
sees an eject notification and eject_device is never reached for
either type today. If the allowlist is extended later, this cleanup
keeps the post-eject state consistent.
Signed-off-by: Dylan Reid <dgreid@fb.com>
Although a response is sent to the client these errors are not logged in
the log file making it hard to cross reference these with other log
entries.
Signed-off-by: Rob Bradford <rbradford@meta.com>
The old implementation used an ever monotonically increasing u32 counter
to allocate new GSIs. The counter increased every time a new GSI was
allocated, and freeing GSIs was not possible. Thus, Cloud Hypervisor
can run out of GSIs and panics. This currently happened at the 1024th
GSI [0]. Further, this caused the `KVM_SET_GSI_ROUTING` ioctl to carry
much more payload than needed.
This new implementation uses a bitmap for proper tracking of resources
and can gracefully free GSIs - this is abstracted in type
InterruptAllocator.
Please note that this commit only replaces the old mechanism. The next
commit will introduce freeing used GSIs automatically when an
InterruptRoute is dropped.
While being on this, we also propagate the errors that the allocator may
throw where necessary.
Co-authored-by: Sebastian Eydam <sebastian.eydam@cyberus-technology.de>
On-behalf-of: SAP sebastian.eydam@sap.com
Signed-off-by: Sebastian Eydam <sebastian.eydam@cyberus-technology.de>
On-behalf-of: Philipp Schuster@sap.com
Signed-off-by: Philipp Schuster <philipp.schuster@cyberus-technology.de>
When an SEV-SNP guest transitions pages from private to shared via
KVM_HC_MAP_GPA_RANGE, punch holes in the corresponding guest_memfd
backing it. Without this the balloon driver's `set_memory_decrypted()`
path transitions the page attributes but the physical memory stays
pinned in guest_memfd, making virtio-balloon ineffective for memory
overcommit with confidential VMs. Even without ballooning these pages
are unused by the guest so consume resources. This mirrors the hole
punching that the balloon device does for releasing pages.
The memory_slots Arc is cloned into each KvmVcpu at creation so the
punch can happen in the vcpu thread.
Assisted-by: Claude:claude-opus-4-6
Signed-off-by: Rob Bradford <rbradford@meta.com>
Prefer /dev/userfaultfd (Linux 6.1+) over the userfaultfd(2) syscall
for obtaining userfaultfd file descriptors. The device path bypasses
the capable(CAP_SYS_PTRACE) and vm.unprivileged_userfaultfd sysctl
checks that block the syscall in user-namespaced containers, using
file permissions instead.
Falls back to the syscall on older kernels or when the device node
does not exist.
Signed-off-by: Roberto Campesato <render@metalabs.org>
Assisted-by: Claude:claude-opus-4-6
Import common std types used by the userfaultfd wrapper instead of
spelling the full paths at every use site.
Signed-off-by: Keith Adler <kadler@cloudflare.com>
Allow VFIO devices to list BAR indices that should not be
mmapped into the guest. This lets operators skip large BARs that
are known not to be used by their workload.
When a BAR is skipped, the log also calls out that P2P DMA
mapping is skipped because the VFIO DMA map path uses the same
mmap backing.
Signed-off-by: Damian Barabonkov <dbctl@pm.me>
Assisted-by: OpenCode:gpt-5.5
The unit tests added in bf3279f09 built sparse files by writing only
at one offset and assuming the surrounding pages stayed unallocated.
That breaks on shmem/tmpfs with huge=within_size: kernel 6.10+ added
large-folio support to shmem, and on first write the kernel allocates
one folio whose order is the largest power-of-two number of pages
that fits inside the file size (capped at PMD-size). For a 64 KiB
test file the very first pwrite anywhere allocates a 64 KiB folio
covering the whole file, so SEEK_HOLE never reports a hole and
written_pages_show_as_data_extents,
sparse_file_yields_extents_at_written_positions, and
single_extent_at_zero_offset all fail. memfd_create lives on shmem
too and inherits the same THP policy from
/sys/kernel/mm/transparent_hugepage/shmem_enabled, so the problem is
not /tmp-specific.
Fix the fixtures, not the production code: build each test file via
a new sparse_layout() helper that writes the requested data extents
and then fallocate(FALLOC_FL_PUNCH_HOLE)s every gap. PUNCH_HOLE is
the explicit "deallocate these pages" syscall and is honored by every
Linux filesystem we run tests on (tmpfs, ext4, xfs, btrfs); the
kernel splits any large folio overlapping the punched range. The
resulting SEEK_DATA/SEEK_HOLE map matches the spec exactly regardless
of folio/THP policy.
For single_extent_at_zero_offset the dst side still loses to the
folio allocator -- writing 8 KiB into a 64 KiB tmpfs file allocates
a 64 KiB folio whether we want it or not -- so the previous
meta.blocks()-based sparseness assertion (which tested the filesystem,
not our code) is replaced with a sentinel pre-fill: dst starts filled
with 0xFE and the post-condition is that bytes outside the
source-data extent are still 0xFE. That directly verifies
write_region_sparse only touched the data extent without depending on
dst-side hole reporting.
Side effect: extent_at_non_zero_src_offset,
two_regions_in_same_destination_file_at_dst_offset, and
round_trip_sparse_write_then_read previously passed by accident on
hosts with mTHP-on-shmem -- their src memfds reported the whole file
as data so write_region_sparse silently fell into a dense copy of
zeros + data. With sparse_layout() the sources are genuinely sparse
and those tests now exercise the sparse path on every host.
Tested on tmpfs (huge=within_size) and ext4 (TMPDIR=/var/tmp); all 9
tests pass on both with no skips.
Assisted-by: Claude:Opus-4.7
Signed-off-by: Dylan Reid <dgreid@fb.com>
Rate limiting is implemented in the virtio device layer and does not
apply to vhost-user devices which delegate I/O handling to an external
process.
Add validation to reject configurations where vhost_user is enabled
along with rate limiting options (bw_size, ops_size, or
rate_limit_group) for both disk and network devices.
This prevents users from mistakenly configuring rate limiting that would
be silently ignored when using vhost-user backends.
Signed-off-by: Rob Bradford <rbradford@meta.com>
This extends migration to also support paused VMs, preserving the
paused state on the destination.
Changes:
- Add CompletePaused protocol command that finalizes migration without
resuming the VM on the destination
- Skip the pause step during migration if the VM is already paused
- On migration failure, only restore the running state if
the VM was originally running (not paused)
Signed-off-by: Nguyen Dinh Phi <phind.uet@gmail.com>
KSM will not attempt to merge pages that are mapped as MAP_SHARED, so
configuring memory with both mergeable and shared options is invalid.
Add validation to reject configurations where both options are enabled
for memory or memory zones.
Signed-off-by: Rob Bradford <rbradford@meta.com>
With the `mshv` feature enabled, the immutable `data` `Vec` is mutated
via a pointer. This violates Rust aliasing rules. Fixed by cloning the
`Vec` to a mutable instance when the `mshv` feature is active.
Signed-off-by: Julian Schindel <mail@arctic-alpaca.de>
Add KvmSevSnpIdBlock and KvmSevSnpIdAuth structs matching the AMD
SEV-SNP Firmware ABI Spec (Rev 1.58), and build them from the IGVM
SNP ID block directive during launch finish. This properly populates
id_block_uaddr/id_auth_uaddr in KVM_SEV_SNP_LAUNCH_FINISH and derives
auth_key_en from the assembled author key, matching QEMU's behavior.
Thread the guest policy from sev_snp_init to launch_finish via an
atomic on KvmVm so the ID block gets the correct policy value.
Also track has_snp_id_block in IgvmLoadedInfo to enable the ID block
based on whether the IGVM file actually contains one, rather than
hardcoding it for KVM.
Signed-off-by: Ruben Hakobyan <hruben@meta.com>
Extract the SNP guest policy from IGVM initialization headers when
available, falling back to the default policy. This matches QEMU's
behaviour where only a non-zero IGVM policy overrides the default.
Signed-off-by: Ruben Hakobyan <hruben@meta.com>
Preserve the original IGVM import order for KVM SNP launch updates.
The launch digest is order-sensitive, so only coalesce adjacent pages
that already share the same page type and size. MSHV continues to
sort by GPA for hypercall batching.
Signed-off-by: Kevin Hui <kevinhui@meta.com>
Introduce the KVM_SNP_PAGE_TYPE_ZERO page type for ZERO
pages. AMD SEV SNP can accept ZERO pages as a page in which
the page memory is functionally just zeroes
Signed-off-by: Kevin Hui <kevinhui@meta.com>
Verify the SEV hash table layout, GUID placement, kernel/initrd/cmdline
digest values, and the setup_sects > boot_params size branch. These
guard against silent regressions in the launch digest computation.
Signed-off-by: Kevin Hui <kevinhui@meta.com>
This introduces the kernel hashes measured boot table into
cloud hypervisor if a cmdline and kernel is passed into an
SEV-SNP CVM, incorporating a kernel/cmdline/optional initrd
into a memory page that is measured into the launch digest
of a SEV-SNP CVM. If both --kernel and --cmdline are not
provided, we do not insert this data page
Signed-off-by: Kevin Hui <kevinhui@meta.com>
For KVM SEV-SNP guests, the VMM should not modify the kernel
boot header before sending it via fw_cfg. The guest firmware is expected
to set fields like type_of_loader itself.
For upcoming measured boot logic for SEV-SNP, modifying `type_of_loader`
causes the kernel hash computed by the VMM to diverge from the hash that
`sev-snp-measure` (and the guest firmware) compute, resulting in a
launch measurement mismatch.
This matches QEMU's behavior, which skips kernel header modifications
for confidential guests so the data sent via fw_cfg matches the
original kernel file provided by the user.
Signed-off-by: Ruben Hakobyan <hruben@meta.com>
Walk the input snapshot file extent by extent via lseek(SEEK_DATA) /
lseek(SEEK_HOLE) within each region's slot and read only those bytes
into guest RAM via the existing read_volatile_from primitive. Holes are
left as the guest mapping's natural zero-fill, which matches the source
content.
Symmetric counterpart to sparse-write on snapshot. Works for both new
sparse snapshots and old dense snapshots: a dense file has no holes, so
SEEK_DATA returns the full range as one extent and the I/O pattern
matches the previous behaviour.
If the input file's filesystem does not support SEEK_HOLE the code falls
back to the existing dense read path.
Measured on a 4 GiB shared-memory VM (2 vCPUs, ~340 MiB touched), total
restore time as reported by the in-tree timing instrumentation:
Before (dense): ~1487ms, reads 4.0 GiB from file
After (sparse): ~136ms, reads 340 MiB from file (92% less I/O, 11x faster)
Assisted-by: Claude:Opus-4.7
Signed-off-by: Rob Bradford <rbradford@meta.com>
For memfd-backed guest RAM regions, walk the backing fd extent by extent
via lseek(SEEK_DATA) / lseek(SEEK_HOLE) and write each populated extent
into the snapshot file's per-region slot via
std::os::unix::fs::FileExt::write_at. Pre-size the file with
set_len(total): on filesystems that support sparse files unwritten bytes
become real holes; on others the kernel zero-fills the allocation, which
is still byte-correct.
If set_len fails (some FUSE backends reject ftruncate-extend with
EOPNOTSUPP), fall back entirely to the dense write path which streams
bytes sequentially via write_volatile_to and never writes past the
growing EOF.
When the guest region has no backing file (anonymous mmap) or the
backing fd does not support SEEK_HOLE (hugetlbfs), fall back to the
dense write path on a per-region basis.
The on-disk byte stream is identical to the dense format from the
perspective of any reader using read/pread/mmap, so old readers see no
change.
Measured on a 4 GiB shared-memory VM (2 vCPUs, ~340 MiB touched), total
snapshot time as reported by the in-tree timing instrumentation:
Before (dense): ~2400ms, 4.0 GiB on disk
After (sparse): ~132ms, 340 MiB on disk (92% smaller, 18x faster)
Assisted-by: Claude:Opus-4.7
Signed-off-by: Rob Bradford <rbradford@meta.com>
Adds next_data_extent: a streaming helper that returns the next
populated extent within a window of a file descriptor using
lseek(SEEK_DATA) / lseek(SEEK_HOLE). Used by subsequent commits to walk
the snapshot file extent-by-extent without collecting the full extent
list.
Returns an error on fds or filesystems without SEEK_HOLE support so the
caller can fall back to a dense write path.
Assisted-by: Claude:Opus-4.7
Signed-off-by: Rob Bradford <rbradford@meta.com>
The total RAM in the system needs to consider any hotpluggable RAM that
is hotplugged in as well as the initial static "base" RAM.
Signed-off-by: Rob Bradford <rbradford@meta.com>
The reserve_bootloader_regions() call allocates RAM regions at
KVM-specific addresses (0xffc00000 for stage0, 0xfffffffff000
for VMSA) that are only needed by the KVM SEV-SNP boot path.
The existing #[cfg(all(feature = "kvm", feature = "sev_snp"))]
compile-time guard is insufficient when both 'mshv' and 'kvm'
features are enabled in the same binary. The runtime check only
verified sev_snp_enabled() but not the hypervisor type, causing
these KVM-specific memory regions to be allocated on MSHV.
On MSHV, these spurious RAM mappings at high addresses interfere
with the hypervisor's address space layout. When the guest kernel
subsequently accesses MMIO regions (e.g., IOAPIC at 0xFEC00000),
MSHV incorrectly reports HVMSG_UNACCEPTED_GPA instead of routing
the access through MMIO emulation, crashing the guest.
Add a runtime hypervisor type check to ensure these regions are
only reserved when running on KVM.
Assisted-by: Claude:Opus-4.6
Signed-off-by: Muminul Islam <muislam@microsoft.com>
The SnpCpuidInfo allocation and guest_memory.read() call in the
isolated page import loop are only needed for KVM's CPUID page
retry logic. However, when building with both 'mshv' and 'kvm'
features, #[cfg(feature = "kvm")] alone is insufficient as a
guard because both features compile into the same binary.
Without a runtime hypervisor type check, this code executes on
MSHV as well, reading guest memory at arbitrary GPAs that may
not be valid in the MSHV memory layout. This can cause undefined
behavior or crashes during IGVM loading.
Add #[cfg(feature = "kvm")] to the variable declarations and
wrap the guest_memory.read() call in a runtime check for
HypervisorType::Kvm to ensure it only executes on KVM.
Assisted-by: Claude:Opus-4.6
Signed-off-by: Muminul Islam <muislam@microsoft.com>
The PageTypeConfig for MSHV incorrectly mapped the cpuid field
to HV_ISOLATED_PAGE_TYPE_NORMAL (0) and the secrets field to
HV_ISOLATED_PAGE_TYPE_UNMEASURED (3).
The correct MSHV page type constants are:
- CPUID pages: HV_ISOLATED_PAGE_TYPE_CPUID (5)
- Secrets pages: HV_ISOLATED_PAGE_TYPE_SECRETS (4)
This was introduced in commit 75ed2c9f90 ("vmm: add KVM
SEV-SNP support to IGVM loader") which abstracted page types
into a PageTypeConfig struct but assigned wrong values for the
MSHV variant. Using incorrect page types causes the MSHV
hypervisor to reject or mishandle isolated page imports,
leading to guest boot failure.
Assisted-by: Claude:Opus-4.6
Signed-off-by: Muminul Islam <muislam@microsoft.com>
For SEV-SNP guests using IGVM, the ACPI tables and system
configuration (MP tables, EBDA, SMBIOS, PVH start info, e820)
are provided by the IGVM file. The rsdp_addr is set to None
for these guests to indicate ACPI table creation was skipped.
Commit 7d65187350 ("vmm: make RSDP address optional in
configure_system") removed the guard that prevented calling
configure_system when rsdp_addr is None. This caused MSHV
SEV-SNP guests to crash because configure_system writes to
guest memory locations that conflict with the IGVM-provided
layout.
Restore the guard by only calling configure_system when
rsdp_addr is Some, which preserves the intended behavior
for CVM guests while still allowing the Option<GuestAddress>
refactoring.
Assisted-by: Claude:Opus-4.6
Signed-off-by: Muminul Islam <muislam@microsoft.com>
Rather than calculate in the DeviceManager and pass it through do it in
the device where it already has all the required information.
Signed-off-by: Rob Bradford <rbradford@meta.com>
# TL;DR
In https://github.com/cloud-hypervisor/cloud-hypervisor/pull/7990 we
replaced the old deadlock with another deadlock. This commit finally
resolves (hopefully) all dead locks on that code path by not holding
`CpuManager::vcpu_states` while waiting for vCPU pause acknowledgements.
A vCPU can receive the pause kick while servicing the ACPI CPU hotplug
MMIO device, and that MMIO path also needs `vcpu_states`. Holding the
mutex across the wait phase deadlocks pause against that MMIO access.
# Problem
`signal_vcpus()` used to lock `CpuManager::vcpu_states` for the whole
function, signal every vCPU, and then wait for each vCPU to acknowledge
the kick.
That lock scope is too wide. A vCPU is allowed to observe the kick in
userspace rather than returning directly from `KVM_RUN`. During boot,
`vcpu0` can be in an MMIO access on the ACPI CPU hotplug device when
pause arrives. `AcpiCpuHotplugController::read()` and `write()` both
lock `vcpu_states` to inspect or update the selected vCPU state.
The deadlock looks like this:
VMM thread vCPU thread
---------- ----------
lock(vcpu_states)
signal_vcpus()
wait for ack ---------------------> receives pause kick
enters ACPI CPU hotplug MMIO
lock(vcpu_states) [blocks]
wait for ack <--------------------- cannot set vcpu_run_interrupted
The VMM thread waits for `vcpu_run_interrupted` to flip, but the vCPU
cannot reach the pause acknowledgement path because it is sleeping on
the same mutex.
The debug logs matched that cycle exactly: signal delivery kept
working, `vcpu0` stayed in one unmatched `run()` invocation, the stuck
thread sampled in `futex_do_wait`, and the backtrace pointed at
`AcpiCpuHotplugController::read()`.
# Reproducer
This was reproducible by continuously issuing `pause()` / `resume()`
from while a Linux guest was still booting. That boot-tim window
reliably exercises the ACPI CPU hotplug MMIO access that participates in
the deadlock. Once the guest had finished booting, the problem became
much harder to trigger (as there is no MMIO operation without explicit
CPU plugging).
# Solution
Keep the existing two-phase behavior so all vCPUs are still signalled
before the wait phase, but narrow the lifetime of the `vcpu_states`
mutex. Reacquire it only long enough to access one `VcpuState` at a
time in each phase.
That preserves the original pause semantics and the fast signal-all /
wait-all structure, while removing the lock inversion with the ACPI CPU
hotplug MMIO path.
This also remains safe if a vCPU is hot-removed while pause is in
progress. Hot-remove does not shrink `vcpu_states`; it stops the thread
and clears the `VcpuState` handle in place. `signal_vcpus()` can
therefore snapshot the vector length up front, and if a vCPU disappears
between the signal and wait phases,
`wait_until_signal_acknowledged()` will observe `handle.is_none()` and
return successfully.
The interruption handshake itself lives in atomics inside each
`VcpuState`. The outer mutex is only needed to reach the state objects,
not to keep the acknowledgement protocol correct. Dropping the mutex
between iterations therefore does not weaken the pause protocol, but it
does allow MMIO handlers and other `vcpu_states` users to make forward
progress while the VMM waits for the kick to be observed.
On-behalf-of: SAP philipp.schuster@sap.com
Signed-off-by: Philipp Schuster <philipp.schuster@cyberus-technology.de>
All disk format backends now implement AsyncFullDiskFile directly.
The DiskBackend enum that dispatched between Legacy and Next arms
is no longer needed since the factory returns trait objects and vmm
no longer constructs format types manually.
Replace DiskBackend with Box<dyn AsyncFullDiskFile> in the Block
struct and its constructor. Remove the DiskBackend::Next wrapping
in device_manager and the fuzz target.
Signed-off-by: Anatol Belski <anbelski@linux.microsoft.com>
Replace the manual match block that constructed each disk format
backend with a single call to block::factory::open_disk. The factory
handles file opening, format detection, async/sync backend selection
and logging internally.
Remove imports and errors for individual format types and helper
functions that are no longer called directly. The factory returns
BlockError with path and operation context attached, surfaced via
the existing Disk variant.
Signed-off-by: Anatol Belski <anbelski@linux.microsoft.com>
Microsoft Hypervisor's hyperclear technology supports at most two
threads per core.
Practically all x64 CPUs nowadays only support two threads per core.
Enforce this in the common code.
Assisted-by: OpenAI:ChatGPT-5.4
[Test cases written by an LLM ]
Signed-off-by: Wei Liu <liuwe@microsoft.com>