Using IDE (PIO) and writing directly to the PIIX4 PM device, we can test
what happens when an I/O error occurs while the VM is suspended.

The moment we resume the request while the VM is suspended is the moment
of the SUSPENDED -> IO_ERROR transition, and the moment we issue `cont`
is the reverse IO_ERROR -> SUSPENDED transition.

Signed-off-by: Hanna Czenczek <[email protected]>
---
 .../tests/io-error-during-suspend             | 155 ++++++++++++++++++
 .../tests/io-error-during-suspend.out         |  22 +++
 2 files changed, 177 insertions(+)
 create mode 100755 tests/qemu-iotests/tests/io-error-during-suspend
 create mode 100644 tests/qemu-iotests/tests/io-error-during-suspend.out

diff --git a/tests/qemu-iotests/tests/io-error-during-suspend 
b/tests/qemu-iotests/tests/io-error-during-suspend
new file mode 100755
index 00000000000..5ef741d96b4
--- /dev/null
+++ b/tests/qemu-iotests/tests/io-error-during-suspend
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+# group: rw
+#
+# Reproduce an I/O error while the guest is entering suspend
+#
+# Copyright (C) 2026 Red Hat, Inc.
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Creator/Owner: Hanna Czenczek <[email protected]>
+
+import iotests
+from iotests import filter_qmp_event, log
+
+iotests.script_initialize()
+
+# PC machine type required for PIIX4 and IDE
+if iotests.qemu_default_machine != 'pc':
+    iotests.notrun('Requires a PC machine')
+
+def vm_qemu_io(qvm, dev_id, cmd):
+    log(f'[HMP] qemu-io to {dev_id}: {cmd}')
+    command_line=f'qemu-io -d {dev_id} "{cmd}"'
+    log(qvm.qmp('human-monitor-command', command_line=command_line))
+
+def await_event(qvm, match):
+    evt = qvm.event_wait(match, timeout=30.0)
+    assert evt is not None
+    return evt
+
+def find_pci(qvm, vendor, device):
+    """
+    Return bus, slot, function of the device matching vendor and device ID.
+    """
+
+    for bus in qvm.qmp('query-pci')['return']:
+        for dev in bus['devices']:
+            if dev['id']['vendor'] == vendor and dev['id']['device'] == device:
+                return dev['bus'], dev['slot'], dev['function']
+
+    raise RuntimeError(f'PCI device {vendor:04x}:{device:04x} not found')
+
+def enter_s3(qvm):
+    """
+    Enter suspend via the PIIX4 PM device
+    """
+
+    bus, slot, function = find_pci(qvm, 0x8086, 0x7113)
+    pci_pm_addr = 0x80000000 | (bus << 16) | (slot << 11) | (function << 8)
+
+    # Config registers:
+    # - 0x40: Set the power management I/O range base
+    # - 0x80: Bit 0 enables that range
+    # I/O range:
+    # - +4: PM1a_CNT
+    #   - Bits 10..=12: SLEEP_TYPE (1 for suspend, see `acpi_pm_cnt_write()`)
+    #   - Bit 13: SLEEP_ENABLE
+
+    arbitrary_pm_io_base = 0xb000
+    log('Setting up PIIX4 PM I/O range')
+    # Setting the base address
+    qvm.qtest(f'outl 0xcf8 {pci_pm_addr + 0x40:#x}')
+    qvm.qtest(f'outl 0xcfc {arbitrary_pm_io_base:#x}')
+    # Enabling the range
+    qvm.qtest(f'outl 0xcf8 {pci_pm_addr + 0x80:#x}')
+    qvm.qtest('outb 0xcfc 0x1')
+
+    log('Going to sleep')
+    pm1a_cnt = arbitrary_pm_io_base + 4
+    pm1a_value = (1 << 10) | (1 << 13)
+    qvm.qtest(f'outw {pm1a_cnt:#x} {pm1a_value:#x}')
+
+def ide_issue_pio_read(qvm, lba, sectors):
+    """
+    Issue a read request from the master device.
+    Does not read the data through PIO, though.
+    """
+
+    for port, val in (
+            (0x1f6, 0xe0 | ((lba >> 24) & 0xf)), # 0x40 selects LBA
+            (0x1f2, sectors),
+            (0x1f3, lba & 0xff),
+            (0x1f4, (lba >> 8) & 0xff),
+            (0x1f5, (lba >> 16) & 0xff),
+            (0x1f7, 0x20), # WIN_READ
+        ):
+        qvm.qtest(f'outb {port:#x} {val:#x}')
+
+with iotests.VM() as vm:
+    # So we have PIIX4
+    vm.add_args('-machine', 'pc')
+
+    failing_sector = 42
+    vm.add_blockdev(vm.qmp_to_opts({
+        'driver': 'raw',
+        'node-name': 'test',
+        'file': {
+            'driver': 'blkdebug',
+            'inject-error': [{'event': 'read_aio', 'iotype': 'read',
+                              'sector': str(failing_sector), 'once': 'on'}],
+            'image': {
+                'driver': 'null-co',
+            },
+        },
+    }))
+
+    disk = 'ide-disk'
+    vm.add_device(f'ide-hd,id={disk},drive=test,bus=ide.0,unit=0,'
+                   'rerror=stop,werror=stop')
+
+    vm.launch()
+
+    try:
+        # Issue a read request from a guest device (to the failing sector), and
+        # have it pause before it returns
+        vm_qemu_io(vm, disk, 'break read_aio r0')
+        log('Issuing read')
+        ide_issue_pio_read(vm, failing_sector, 1)
+        vm_qemu_io(vm, disk, 'wait_break r0')
+
+        # Read request is paused now, let's suspend the VM
+        log('Entering suspend')
+        enter_s3(vm)
+        log(await_event(vm, 'SUSPEND'), filters=[filter_qmp_event])
+
+        # Should be suspended now
+        log('[QMP] query-status')
+        log(vm.qmp('query-status'))
+    except Exception:
+        # The VM will not be able to quit without this request finished, which
+        # (without this block) would make the test hang instead of exit if
+        # something unexpected goes wrong
+        vm_qemu_io(vm, disk, 'resume r0')
+        raise
+
+    # Resume the request, which should run into an error thanks to our blkdebug
+    # configuration
+    vm_qemu_io(vm, disk, 'resume r0')
+    log(await_event(vm, 'BLOCK_IO_ERROR'), filters=[filter_qmp_event])
+    log(await_event(vm, 'STOP'), filters=[filter_qmp_event])
+
+    # Should be stopped now (not just suspended)
+    log('[QMP] query-status')
+    log(vm.qmp('query-status'))
+
+    log('[QMP] cont')
+    log(vm.qmp('cont'))
+
+    log(await_event(vm, 'RESUME'), filters=[filter_qmp_event])
+
+    # Should be suspended again (error was injected 'once', so should be gone)
+    log('[QMP] query-status')
+    log(vm.qmp('query-status'))
+
+    vm.shutdown()
diff --git a/tests/qemu-iotests/tests/io-error-during-suspend.out 
b/tests/qemu-iotests/tests/io-error-during-suspend.out
new file mode 100644
index 00000000000..353f18250ad
--- /dev/null
+++ b/tests/qemu-iotests/tests/io-error-during-suspend.out
@@ -0,0 +1,22 @@
+[HMP] qemu-io to ide-disk: break read_aio r0
+{"return": ""}
+Issuing read
+[HMP] qemu-io to ide-disk: wait_break r0
+{"return": ""}
+Entering suspend
+Setting up PIIX4 PM I/O range
+Going to sleep
+{"event": "SUSPEND", "timestamp": {"microseconds": "USECS", "seconds": "SECS"}}
+[QMP] query-status
+{"return": {"running": false, "status": "suspended"}}
+[HMP] qemu-io to ide-disk: resume r0
+{"return": ""}
+{"data": {"action": "stop", "device": "", "node-name": "test", "nospace": 
false, "operation": "read", "qom-path": "/machine/peripheral/ide-disk", 
"reason": "Input/output error"}, "event": "BLOCK_IO_ERROR", "timestamp": 
{"microseconds": "USECS", "seconds": "SECS"}}
+{"event": "STOP", "timestamp": {"microseconds": "USECS", "seconds": "SECS"}}
+[QMP] query-status
+{"return": {"running": false, "status": "io-error"}}
+[QMP] cont
+{"return": {}}
+{"event": "RESUME", "timestamp": {"microseconds": "USECS", "seconds": "SECS"}}
+[QMP] query-status
+{"return": {"running": false, "status": "suspended"}}
-- 
2.55.0


Reply via email to