@@ -20,6 +20,7 @@ def test_hotplug_block(uvm_any_with_pci):
2020 Test hotplugging a block device after VM start.
2121 Test that the device appears in lspci and is usable.
2222 Test that invalid hotplug request are rejected.
23+ Test hot-unplugging the device.
2324 """
2425 vm = uvm_any_with_pci
2526
@@ -94,12 +95,37 @@ def test_hotplug_block(uvm_any_with_pci):
9495 _ , lspci_final , _ = vm .ssh .check_output ("lspci -n" )
9596 assert lspci_final == lspci_after
9697
98+ # Unplugging a non-existent device must be rejected
99+ with pytest .raises (RuntimeError , match = "Device not found" ):
100+ vm .api .drive .delete ("nonexistent" )
101+
102+ # Unplugging the root block device must be rejected
103+ with pytest .raises (RuntimeError , match = "Cannot unplug root device" ):
104+ vm .api .drive .delete ("rootfs" )
105+
106+ # No unplug notification mechanism exists yet, so the guest needs to
107+ # gracefully prepare for the detach before the host issues the unplug.
108+ vm .ssh .check_output ("umount /tmp/block0_mnt" )
109+ vm .ssh .check_output (f"echo 1 > /sys/bus/pci/devices/0000:{ bdf } /remove" )
110+
111+ # Unplug the block device
112+ vm .api .drive .delete ("block0" )
113+
114+ # Verify the device is gone
115+ _ , lspci_after_unplug , _ = vm .ssh .check_output ("lspci -n" )
116+ assert lspci_after_unplug == lspci_before
117+
118+ # Unplugging the same device again must be rejected
119+ with pytest .raises (RuntimeError , match = "Device not found" ):
120+ vm .api .drive .delete ("block0" )
121+
97122
98123def test_hotplug_pmem (uvm_any_with_pci ):
99124 """
100125 Test hotplugging a pmem device after VM start.
101126 Test that the device appears in lspci and is usable.
102127 Test that invalid hotplug request are rejected.
128+ Test hot-unplugging the device.
103129 """
104130 vm = uvm_any_with_pci
105131
@@ -174,12 +200,33 @@ def test_hotplug_pmem(uvm_any_with_pci):
174200 _ , lspci_final , _ = vm .ssh .check_output ("lspci -n" )
175201 assert lspci_final == lspci_after
176202
203+ # Unplugging a non-existent device must be rejected
204+ with pytest .raises (RuntimeError , match = "Device not found" ):
205+ vm .api .pmem .delete ("nonexistent" )
206+
207+ # No unplug notification mechanism exists yet, so the guest needs to
208+ # gracefully prepare for the detach before the host issues the unplug.
209+ vm .ssh .check_output ("umount /tmp/pmem0_mnt" )
210+ vm .ssh .check_output (f"echo 1 > /sys/bus/pci/devices/0000:{ bdf } /remove" )
211+
212+ # Unplug the pmem device
213+ vm .api .pmem .delete ("pmem0" )
214+
215+ # Verify the device is gone
216+ _ , lspci_after_unplug , _ = vm .ssh .check_output ("lspci -n" )
217+ assert lspci_after_unplug == lspci_before
218+
219+ # Unplugging the same device again must be rejected
220+ with pytest .raises (RuntimeError , match = "Device not found" ):
221+ vm .api .pmem .delete ("pmem0" )
222+
177223
178224def test_hotplug_net (uvm_any_with_pci ):
179225 """
180226 Test hotplugging a net device after VM start.
181227 Test that the device appears in lspci and is usable.
182228 Test that invalid hotplug request are rejected.
229+ Test hot-unplugging the device.
183230 """
184231 vm = uvm_any_with_pci
185232
@@ -260,10 +307,48 @@ def test_hotplug_net(uvm_any_with_pci):
260307 _ , lspci_final , _ = vm .ssh .check_output ("lspci -n" )
261308 assert lspci_final == lspci_after
262309
310+ # Unplugging a non-existent device must be rejected
311+ with pytest .raises (RuntimeError , match = "Device not found" ):
312+ vm .api .network .delete ("nonexistent" )
313+
314+ # No unplug notification mechanism exists yet, so the guest needs to
315+ # gracefully prepare for the detach before the host issues the unplug.
316+ vm .ssh .check_output (f"ip link set { iface_name } down" )
317+ vm .ssh .check_output (f"echo 1 > /sys/bus/pci/devices/0000:{ bdf } /remove" )
318+
319+ # Unplug the net device
320+ vm .api .network .delete (iface1 .dev_name )
321+
322+ # Verify the device is gone
323+ _ , lspci_after_unplug , _ = vm .ssh .check_output ("lspci -n" )
324+ assert lspci_after_unplug == lspci_before
325+
326+ # Unplugging the same device again must be rejected
327+ with pytest .raises (RuntimeError , match = "Device not found" ):
328+ vm .api .network .delete (iface1 .dev_name )
329+
330+
331+ def test_unplug_root_pmem (microvm_factory , guest_kernel_acpi , rootfs ):
332+ """
333+ Unplugging the root pmem device must be rejected.
334+ """
335+ vm = microvm_factory .build (guest_kernel_acpi , rootfs , pci = True )
336+ vm .memory_monitor = None
337+ vm .monitors = []
338+ vm .spawn ()
339+ vm .basic_config (add_root_device = False )
340+ vm .add_pmem ("pmem_root" , rootfs , root_device = True )
341+ vm .add_net_iface ()
342+ vm .start ()
343+
344+ with pytest .raises (RuntimeError , match = "Cannot unplug root device" ):
345+ vm .api .pmem .delete ("pmem_root" )
346+
263347
264348def test_hotplug_no_pci (uvm_any_without_pci ):
265349 """
266- Hotplugging any device type must be rejected when PCI is not enabled.
350+ Hotplugging and unplugging any device type must be rejected when PCI is not
351+ enabled.
267352 """
268353 vm = uvm_any_without_pci
269354
@@ -294,6 +379,15 @@ def test_hotplug_no_pci(uvm_any_without_pci):
294379 guest_mac = iface1 .guest_mac ,
295380 )
296381
382+ with pytest .raises (RuntimeError , match = "PCI is not enabled" ):
383+ vm .api .drive .delete ("block0" )
384+
385+ with pytest .raises (RuntimeError , match = "PCI is not enabled" ):
386+ vm .api .pmem .delete ("pmem0" )
387+
388+ with pytest .raises (RuntimeError , match = "PCI is not enabled" ):
389+ vm .api .network .delete ("eth0" )
390+
297391
298392def test_hotplug_preserved_after_snapshot (uvm_any_with_pci , microvm_factory ):
299393 """
@@ -352,8 +446,8 @@ def test_hotplug_max_devices(uvm_any_with_pci):
352446 vm = uvm_any_with_pci
353447
354448 # Count how many PCI slots are already in use
355- _ , lspci , _ = vm .ssh .check_output ("lspci -n" )
356- used_slots = len (lspci .strip ().splitlines ())
449+ _ , lspci_initial , _ = vm .ssh .check_output ("lspci -n" )
450+ used_slots = len (lspci_initial .strip ().splitlines ())
357451 free_slots = pci_max_slots - used_slots
358452
359453 for i in range (free_slots ):
@@ -369,8 +463,8 @@ def test_hotplug_max_devices(uvm_any_with_pci):
369463
370464 # Verify all PCI slots are occupied
371465 vm .ssh .check_output ("echo 1 > /sys/bus/pci/rescan" )
372- _ , lspci , _ = vm .ssh .check_output ("lspci -n" )
373- assert len (lspci .strip ().splitlines ()) == pci_max_slots
466+ _ , lspci_full , _ = vm .ssh .check_output ("lspci -n" )
467+ assert len (lspci_full .strip ().splitlines ()) == pci_max_slots
374468
375469 # The next hotplug must fail — no PCI slots left
376470 host_file = drive_tools .FilesystemFile (
@@ -385,3 +479,37 @@ def test_hotplug_max_devices(uvm_any_with_pci):
385479 is_root_device = False ,
386480 is_read_only = False ,
387481 )
482+
483+ # Unplug all hotplugged devices
484+ for i in range (free_slots ):
485+ vm .api .drive .delete (f"block{ i } " )
486+
487+ # Remove the stale devices from the guest
488+ new_bdfs = [
489+ l .split ()[0 ]
490+ for l in set (lspci_full .strip ().splitlines ())
491+ - set (lspci_initial .strip ().splitlines ())
492+ ]
493+ for bdf in new_bdfs :
494+ vm .ssh .check_output (f"echo 1 > /sys/bus/pci/devices/0000:{ bdf } /remove" )
495+
496+ # Verify we're back to the initial number of devices
497+ _ , lspci , _ = vm .ssh .check_output ("lspci -n" )
498+ assert len (lspci .strip ().splitlines ()) == used_slots
499+
500+ # Re-plug all devices to verify the slots were truly freed
501+ for i in range (free_slots ):
502+ host_file = drive_tools .FilesystemFile (
503+ os .path .join (vm .fsfiles , f"block_re{ i } " ), size = 1
504+ )
505+ vm .api .drive .put (
506+ drive_id = f"block_re{ i } " ,
507+ path_on_host = vm .create_jailed_resource (host_file .path ),
508+ is_root_device = False ,
509+ is_read_only = False ,
510+ )
511+
512+ # Verify all PCI slots are occupied again
513+ vm .ssh .check_output ("echo 1 > /sys/bus/pci/rescan" )
514+ _ , lspci , _ = vm .ssh .check_output ("lspci -n" )
515+ assert len (lspci .strip ().splitlines ()) == pci_max_slots
0 commit comments