On this page
- Attaching a disk
- Reading and writing sectors
- Fault injection
- Inject EIO
- Inject slow I/O
- Combine modes
- Concurrent injection during I/O
- Inspecting state
- Clearing
- Detaching a disk
- Common patterns
- "Does the guest retry after a transient EIO?"
- "Does the filesystem remount read-only after EIO?"
- "Does the guest panic on EIO at boot?"
- Multiple disks per VM
- Caveats
- See also
Disks and fault injection
Provium's disk support has two goals: give the test direct sector-level access to the backing image, and inject faults that exercise the guest's error-handling paths.
The exhaustive method reference is on Disk.
Attaching a disk
local img = "/tmp/test.img"
-- Pre-create a backing file; Provium does not auto-create.
io.open(img, "w"):write(string.rep("\0", 1024 * 1024)):close()
local vm = provium:vm("v", "peios"):boot()
local disk = vm:attach_disk({id = "vda", size = 1024 * 1024, image = img})
Opts:
| Field | Default | Effect |
|---|---|---|
id |
attached-<vm_name> |
Disk identifier within the VM. Use this to re-look-up via vm:disk(id). |
size |
4 GiB | Modeled disk size. Used by disk:size() when no image is attached. |
image |
none | Path to the backing file. Required for read_sectors / write_sectors. |
Reading and writing sectors
Sectors are 512 bytes throughout. Offsets and counts are in sectors, not bytes.
-- Read sector 0 (the first 512 bytes).
local sec0 = disk:read_sectors(0, 1)
-- Read 4 sectors starting at sector 100 (bytes 51200..53247).
local block = disk:read_sectors(100, 4)
assert(#block == 4 * 512)
-- Write at sector 50.
disk:write_sectors(50, "hello, sector 50")
Without a backing image, both ops error with a no backing image — disk:with_image required message.
Fault injection
Three modes:
| Mode | Effect |
|---|---|
eio_read |
Every read_sectors short-circuits to EIO. |
eio_write |
Every write_sectors short-circuits to EIO. |
slow |
Every read_sectors / write_sectors sleeps 50 ms before doing the I/O. |
Modes are activated with disk:fault_inject(mode) and cleared with disk:clear_faults(). Multiple modes can be active simultaneously — slow + eio_read delays for 50 ms then errors.
Inject EIO
test("guest sees EIO on read", function(t)
-- … attach disk with backing image …
disk:fault_inject("eio_read")
local ok, err = pcall(function() disk:read_sectors(0, 1) end)
t:assert(not ok)
t:assert(tostring(err):find("EIO"))
end)
Inject slow I/O
test("write completes despite slowness", function(t)
disk:fault_inject("slow")
local started = os.time()
disk:write_sectors(0, "data") -- still succeeds
local elapsed_ms = (os.time() - started) * 1000
-- The 50 ms sleep is per-call. Tests that need a finer dial can
-- patch the SLOW_FAULT_MS constant in the harness source.
end)
Combine modes
test("slow EIO is still EIO", function(t)
disk:fault_inject("slow")
disk:fault_inject("eio_read")
local ok, err = pcall(function() disk:read_sectors(0, 1) end)
t:assert(not ok) -- slow doesn't change the outcome
t:assert(tostring(err):find("EIO"))
end)
Concurrent injection during I/O
The harness re-checks the fault set after the slow-fault sleep AND after the actual I/O completes. So a test thread that flips eio_read mid-call still sees the EIO take effect:
test("EIO injected during slow read still fires", function(t)
disk:fault_inject("slow")
-- Spawn a worker that flips EIO while the read is sleeping.
local w = vm:spawn_worker()
w:run_async("sleep 0.025 && true") -- placeholder: in a real test
-- the worker would call
-- disk:fault_inject via
-- a parallel control plane
-- The expectation: regardless of when the EIO injection lands
-- (during the 50 ms sleep or during the actual read), the
-- post-I/O recheck makes sure the test sees an EIO error.
end)
In practice, you usually inject up-front and then act, or use a worker for the parallel injection. The post-I/O recheck is mostly insurance against silent races.
Inspecting state
local active = disk:active_faults() -- {"eio_read", "slow"}
disk:is_detached() -- false
Clearing
disk:clear_faults()
local r = disk:read_sectors(0, 1) -- succeeds
Detaching a disk
disk:detach()
local ok = pcall(function() disk:read_sectors(0, 1) end)
assert(not ok) -- "disk is detached"
disk:detach() issues a best-effort QMP device_del against the parent VM and marks the local handle detached. If the disk was never QMP-added (host-bookkeeping-only attach), device_del silently surfaces "Device not found" but the local detached flag is still set.
Common patterns
"Does the guest retry after a transient EIO?"
test("guest retries on transient EIO", function(t)
-- Inject EIO.
disk:fault_inject("eio_read")
-- Have the guest start a read in the background.
local proc = vm:run_async("dd if=/dev/vda of=/tmp/out bs=512 count=1")
-- Wait briefly, then clear so retry succeeds.
vm:clock():sleep("100ms")
disk:clear_faults()
local r = proc:wait("5s")
-- If the guest's driver retries, this succeeds. Otherwise dd
-- returned an I/O error.
r:assert_ok()
end)
"Does the filesystem remount read-only after EIO?"
test("filesystem goes read-only after persistent EIO", function(t)
disk:fault_inject("eio_write")
vm:run("echo data > /mnt/test/file"):assert_ok() -- might succeed or fail
-- Force a sync to surface the write.
vm:run("sync")
-- Now check the kernel's view: errors=remount-ro should kick in.
local r = vm:run("findmnt /mnt/test -o OPTIONS")
t:assert(r.stdout:find("ro"))
end)
"Does the guest panic on EIO at boot?"
test("EIO at boot does not panic", function(t)
disk:fault_inject("eio_read")
vm:reset()
-- vm:reset() returns to Booted; check the console for panic strings.
local log = vm:console():read_log()
t:assert(not log:find("kernel panic"))
end)
Multiple disks per VM
local data = vm:attach_disk({id = "vdb", size = 1024 * 1024, image = "/tmp/data.img"})
local logs = vm:attach_disk({id = "vdc", size = 1024 * 1024, image = "/tmp/logs.img"})
-- Inject EIO on data only; logs is unaffected.
data:fault_inject("eio_read")
Use vm:disk(id) to look up an already-attached disk:
local data = vm:disk("vdb")
data:fault_inject("slow")
Caveats
- Sector size is fixed at 512 bytes. Tests that need 4 KiB sectors should expect their guest to layer that on top.
read_sectorsandwrite_sectorsgo directly to the host file, not through QEMU's block backend. This means a test that exercises QEMU's block translation (sparse holes, compression, etc.) will not see those layers — the disk userdata is a direct view of the underlying image bytes.disk:size()reports the live image file size when an image is attached. Test code that resizes the underlying file (truncate,fallocate) sees the new size, not the modeledsizefromattach_disk.
See also
- Disk reference — every method, every error message.
- VM reference —
vm:attach_disk,vm:disk.