diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-06 10:01:58 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-06 10:01:58 +0300 |
| commit | d807c1ad9eb8b176e36300c6ea41744431a05bf0 (patch) | |
| tree | 96b8b6a38aea95905db6280fa10921d61b01c023 /cmd | |
| parent | 17cb27871a6cb5a1c21ca604c2285e7f072478a0 (diff) | |
test(aio): add io_getevents and io_cancel enter coverage
Close the integration-test gaps for two classic-AIO syscalls that the
existing scenario never exercised. The AIO workload only drove
io_setup/io_submit/io_destroy, so io_getevents (nr 208) and io_cancel
(nr 210) had no end-to-end coverage despite their tracer classification
(FamilyAIO, KindNull enter, UNCLASSIFIED ret) being correct by inspection.
cmd/ioworkload/scenario_aio.go:
- Factor the submit scaffolding (temp dir, target file, AIO context) into
withAioTarget so each scenario stays short.
- ioSubmitWrite now returns the submitted iocb pointer (io_cancel needs it).
- aio-getevents: submit, then reap the completion with a blocking
io_getevents (min_nr=1, NULL timeout); asserts the return is a count.
- aio-cancel: submit, then best-effort io_cancel (return ignored: it races
the I/O completion and often yields -EINVAL/-EAGAIN, but enter still
fires), then drain the ring non-blockingly (min_nr=0) so io_destroy has
nothing in flight and we never hang when the cancel left no completion.
integrationtests/aio_test.go:
- TestAioGetevents asserts enter_io_getevents (MinCount 1), mirroring
TestAioSubmit, with io_getevents added to the trace-arg set.
- TestAioCancel asserts ONLY enter_io_cancel (MinCount 1) — never success —
because io_cancel's return is non-deterministic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/ioworkload/scenario_aio.go | 147 | ||||
| -rw-r--r-- | cmd/ioworkload/scenarios.go | 2 |
2 files changed, 140 insertions, 9 deletions
diff --git a/cmd/ioworkload/scenario_aio.go b/cmd/ioworkload/scenario_aio.go index 61bd1ff..f4cecb1 100644 --- a/cmd/ioworkload/scenario_aio.go +++ b/cmd/ioworkload/scenario_aio.go @@ -14,9 +14,11 @@ import ( // distinct from the io_uring_* family. We invoke them raw via Syscall because // the Go standard library does not wrap them. const ( - sysIoSetup = 206 - sysIoDestroy = 207 - sysIoSubmit = 209 + sysIoSetup = 206 + sysIoDestroy = 207 + sysIoGetevents = 208 + sysIoSubmit = 209 + sysIoCancel = 210 // aioMaxEvents is the nr_events count requested from io_setup(2). It is a // plain count (NOT an fd), so the tracer must classify the enter event as @@ -29,6 +31,17 @@ const ( iocbCmdPwrite = 1 ) +// ioEvent mirrors struct io_event from <linux/aio_abi.h> on x86_64 (32 bytes). +// io_getevents(2) fills an array of these from the completion ring; we only +// need the layout to reap a completion, not to trace it (io_getevents args are +// opaque to the tracer, KindNull). +type ioEvent struct { + data uint64 // 0: aio_data echoed from the submitting iocb + obj uint64 // 8: pointer to the originating iocb + res int64 // 16: primary result (bytes transferred, or -errno) + res2 int64 // 24: secondary result +} + // iocb mirrors struct iocb from <linux/aio_abi.h> on x86_64 (64 bytes). It is // the control block io_submit(2) consumes via its iocbpp pointer-array argument. // The tracer treats io_submit's args (ctx_id, nr, iocbpp) as opaque (KindNull), @@ -87,7 +100,58 @@ func aioSetupEinval() error { // AIO family. Note io_submit returns the COUNT of iocbs submitted (here 1), NOT // a byte count, which is why the tracer must classify its return UNCLASSIFIED. func aioSubmit() error { - dir, cleanup, err := makeTempDir("aio-submit") + return withAioTarget("aio-submit", func(ctx uint64, fd int) error { + _, err := ioSubmitWrite(ctx, fd) + return err + }) +} + +// aioGetevents exercises io_getevents(2) end-to-end: it submits one iocb, then +// reaps its completion with io_getevents(ctx, min_nr, nr, events, timeout) +// (sys nr 208 on x86_64). This drives a real io_getevents tracepoint so the +// integration harness can validate enter_io_getevents/exit_io_getevents for the +// AIO family. io_getevents returns the COUNT of events reaped (not a byte +// count), which is why the tracer classifies its return UNCLASSIFIED. +func aioGetevents() error { + return withAioTarget("aio-getevents", func(ctx uint64, fd int) error { + if _, err := ioSubmitWrite(ctx, fd); err != nil { + return err + } + return ioGeteventsReap(ctx) + }) +} + +// aioCancel exercises io_cancel(2): it submits one iocb and then calls +// io_cancel(ctx, iocbp, &result) (sys nr 210 on x86_64). io_cancel is +// non-deterministic — the I/O frequently completes before the cancel runs, so +// the syscall often returns -EINVAL/-EAGAIN — but the enter_io_cancel +// tracepoint fires regardless of the return value, which is all the integration +// harness asserts on. To keep the context valid we still reap any pending +// completion with io_getevents afterwards before tearing down. +func aioCancel() error { + return withAioTarget("aio-cancel", func(ctx uint64, fd int) error { + cbp, err := ioSubmitWrite(ctx, fd) + if err != nil { + return err + } + // Best-effort cancel: ignore the (non-deterministic) return value; + // only the enter tracepoint matters for coverage. + ioCancelRequest(ctx, cbp) + // Drain any pending completion non-blockingly so io_destroy has + // nothing left in flight. We must NOT block here: if the cancel + // succeeded the request produces no completion event, so a blocking + // (min_nr=1) reap would hang. + ioGeteventsDrain(ctx) + return nil + }) +} + +// withAioTarget sets up the common AIO scaffolding shared by the submit-based +// scenarios: a temp dir, a writable target file, and an AIO context. It invokes +// fn with the context id and the target fd, then tears the context and temp dir +// down. Factoring this out keeps the individual scenarios short. +func withAioTarget(label string, fn func(ctx uint64, fd int) error) error { + dir, cleanup, err := makeTempDir(label) if err != nil { return err } @@ -106,14 +170,15 @@ func aioSubmit() error { } defer ioDestroyContext(ctx) - return ioSubmitWrite(ctx, int(f.Fd())) + return fn(ctx, int(f.Fd())) } // ioSubmitWrite submits one IOCB_CMD_PWRITE iocb against fd via io_submit(2). // io_submit takes (ctx_id, nr, iocbpp): an aio_context_t handle (NOT an fd), a // count, and a userspace array of iocb pointers. On success it returns the -// number of iocbs accepted (1 here). -func ioSubmitWrite(ctx uint64, fd int) error { +// number of iocbs accepted (1 here) and the submitted iocb pointer, which +// io_cancel(2) needs to identify the request to cancel. +func ioSubmitWrite(ctx uint64, fd int) (*iocb, error) { buf := []byte("ior-aio-submit\n") cb := iocb{ aioLioOpcode: iocbCmdPwrite, @@ -134,14 +199,78 @@ func ioSubmitWrite(ctx uint64, fd int) error { runtime.KeepAlive(buf) runtime.KeepAlive(cbp) if errno != 0 { - return fmt.Errorf("io_submit: %w", errno) + return nil, fmt.Errorf("io_submit: %w", errno) } if ret != uintptr(len(cbs)) { - return fmt.Errorf("io_submit submitted %d iocbs, want %d", ret, len(cbs)) + return nil, fmt.Errorf("io_submit submitted %d iocbs, want %d", ret, len(cbs)) + } + return cbp, nil +} + +// ioGeteventsReap reaps up to one completion from the AIO context with +// io_getevents(2). It takes (ctx_id, min_nr, nr, events, timeout): we request +// at least one event (min_nr=1) and a NULL timeout so the call blocks until the +// submitted write completes. The aio_context_t handle is NOT an fd and the +// events/timeout pointers are opaque to the tracer (KindNull enter); the return +// is a COUNT of events reaped (UNCLASSIFIED), not a byte count. +func ioGeteventsReap(ctx uint64) error { + var events [1]ioEvent + ret, _, errno := syscall.Syscall6( + sysIoGetevents, + uintptr(ctx), + 1, // min_nr: block until at least one completion is ready + uintptr(len(events)), + uintptr(unsafe.Pointer(&events[0])), + 0, // timeout: NULL -> wait indefinitely + 0, + ) + runtime.KeepAlive(events) + if errno != 0 { + return fmt.Errorf("io_getevents: %w", errno) + } + if ret < 1 { + return fmt.Errorf("io_getevents reaped %d events, want >= 1", ret) } return nil } +// ioGeteventsDrain reaps any already-completed events non-blockingly +// (min_nr=0, NULL timeout) and discards them. It is used by the cancel scenario +// to clear the completion ring without risking a hang when the cancel succeeded +// and left no completion behind. Errors are ignored: this is best-effort +// cleanup, not a tracepoint-bearing assertion path. +func ioGeteventsDrain(ctx uint64) { + var events [1]ioEvent + _, _, _ = syscall.Syscall6( + sysIoGetevents, + uintptr(ctx), + 0, // min_nr=0: return immediately even if nothing is ready + uintptr(len(events)), + uintptr(unsafe.Pointer(&events[0])), + 0, // timeout: NULL + 0, + ) + runtime.KeepAlive(events) +} + +// ioCancelRequest attempts to cancel the in-flight iocb via io_cancel(2), +// which takes (ctx_id, iocb, result). The result io_event receives the +// completion data on a successful cancel. The return value is intentionally +// ignored by callers: io_cancel races the I/O completion and commonly fails +// with -EINVAL/-EAGAIN, but the enter_io_cancel tracepoint fires regardless, +// which is the only thing the integration harness asserts on. +func ioCancelRequest(ctx uint64, cbp *iocb) { + var result ioEvent + _, _, _ = syscall.Syscall( + sysIoCancel, + uintptr(ctx), + uintptr(unsafe.Pointer(cbp)), + uintptr(unsafe.Pointer(&result)), + ) + runtime.KeepAlive(cbp) + runtime.KeepAlive(result) +} + // ioSetupContext calls io_setup(2) and returns the opaque aio_context_t id. func ioSetupContext(nrEvents uint32) (uint64, error) { var ctx uint64 diff --git a/cmd/ioworkload/scenarios.go b/cmd/ioworkload/scenarios.go index 811127a..50f84e1 100644 --- a/cmd/ioworkload/scenarios.go +++ b/cmd/ioworkload/scenarios.go @@ -156,6 +156,8 @@ var scenarios = map[string]func() error{ "aio-setup": aioSetup, "aio-setup-einval": aioSetupEinval, "aio-submit": aioSubmit, + "aio-getevents": aioGetevents, + "aio-cancel": aioCancel, "signals-basic": signalsBasic, "misc-basic": miscBasic, "sched-basic": schedBasic, |
