summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-31 19:04:44 +0300
committerPaul Buetow <paul@buetow.org>2026-05-31 19:04:44 +0300
commitc3177bd82c16429c1bb246d19af76012479f0c01 (patch)
treef71fc68ce009295690a723b49881eed74bae0c48
parentc6a89452bd6872a380d4ce0dc9ec35ea0c66ef9e (diff)
getxattrat: READ-classify return for xattr-get family consistency
getxattrat(2) (Linux 6.13+) returns the xattr value size in bytes, exactly like getxattr/lgetxattr/fgetxattr, but its exit was classified UNCLASSIFIED, so its read bytes were dropped from I/O totals. Classify it as ReadClassified and regenerate the BPF handler (ret_type now READ_CLASSIFIED). Path extraction (args[1], after the dirfd) and the name-not-captured-as-path behaviour were already correct. Update the docs ReadClassified list and the retclassify expectation, and add the first xattr integration coverage: an ioworkload scenario that sets then getxattrat-reads a user xattr on tmpfs, plus a test that asserts enter_getxattrat captures the file path (not the xattr name) and accounts the returned value size as read bytes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rw-r--r--cmd/ioworkload/scenario_xattr.go107
-rw-r--r--cmd/ioworkload/scenarios.go1
-rw-r--r--docs/syscall-tracing-plan.md2
-rw-r--r--integrationtests/xattr_test.go37
-rw-r--r--internal/c/generated_tracepoints.c4
-rw-r--r--internal/c/generated_tracepoints_result.txt2
-rw-r--r--internal/generate/classify.go3
-rw-r--r--internal/generate/retclassify_test.go3
8 files changed, 155 insertions, 4 deletions
diff --git a/cmd/ioworkload/scenario_xattr.go b/cmd/ioworkload/scenario_xattr.go
new file mode 100644
index 0000000..42c679f
--- /dev/null
+++ b/cmd/ioworkload/scenario_xattr.go
@@ -0,0 +1,107 @@
+package main
+
+import (
+ "fmt"
+ "path/filepath"
+ "runtime"
+ "syscall"
+ "unsafe"
+)
+
+// getxattrat is syscall number 464 on amd64 (added in Linux 6.13). Go's
+// syscall package does not yet export SYS_GETXATTRAT, so we invoke it by its
+// raw number. Its signature is:
+//
+// getxattrat(int dfd, const char *pathname, unsigned int at_flags,
+// const char *name, struct xattr_args *uargs, size_t usize)
+//
+// The filesystem PATH is at args[1] (after the dirfd), while args[3] ("name")
+// is the xattr NAME (e.g. "user.ior") and must NOT be captured as a path. The
+// syscall returns the size in bytes of the xattr value (a read byte-count),
+// or -1 on error.
+const sysGetxattrat = 464
+
+// xattrArgs mirrors struct xattr_args from <linux/xattr.h> (Linux 6.13+):
+// a userspace value buffer pointer plus its size and flags.
+type xattrArgs struct {
+ value uint64 // __aligned_u64: pointer to the value buffer
+ size uint32 // size of the value buffer
+ flags uint32 // operation flags (0 for getxattrat)
+}
+
+// xattrGetxattrat creates a file on tmpfs (/tmp), sets a user xattr on it, then
+// reads that xattr back via the raw getxattrat(2) syscall with AT_FDCWD. This
+// exercises ior's getxattrat tracing end-to-end and confirms:
+// - the real filesystem path (args[1]) is captured, NOT the dirfd or the
+// xattr name string at args[3];
+// - the syscall exit is READ-classified so the returned value size is
+// accounted as read bytes, consistent with getxattr/lgetxattr/fgetxattr.
+func xattrGetxattrat() error {
+ dir, cleanup, err := makeTempDir("xattr-getxattrat")
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ path := filepath.Join(dir, "xattrfile.txt")
+ fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644)
+ if err != nil {
+ return fmt.Errorf("open: %w", err)
+ }
+ syscall.Close(fd)
+
+ const xattrName = "user.ior"
+ value := []byte("getxattrat-value")
+ if err := syscall.Setxattr(path, xattrName, value, 0); err != nil {
+ return fmt.Errorf("setxattr: %w", err)
+ }
+
+ if err := callGetxattrat(path, xattrName, len(value)); err != nil {
+ return err
+ }
+ return nil
+}
+
+// callGetxattrat performs the raw getxattrat(AT_FDCWD, path, 0, name, args,
+// sizeof(args)) call and verifies it returns the expected value size.
+func callGetxattrat(path, name string, wantSize int) error {
+ pathBytes, err := syscall.BytePtrFromString(path)
+ if err != nil {
+ return fmt.Errorf("path bytes: %w", err)
+ }
+ nameBytes, err := syscall.BytePtrFromString(name)
+ if err != nil {
+ return fmt.Errorf("name bytes: %w", err)
+ }
+
+ buf := make([]byte, 256)
+ args := xattrArgs{
+ value: uint64(uintptr(unsafe.Pointer(&buf[0]))),
+ size: uint32(len(buf)),
+ flags: 0,
+ }
+
+ // Use a runtime int variable so the negative AT_FDCWD survives the uintptr
+ // conversion: converting the negative constant directly overflows uintptr.
+ dirfd := _AT_FDCWD
+ ret, _, errno := syscall.Syscall6(
+ sysGetxattrat,
+ uintptr(dirfd),
+ uintptr(unsafe.Pointer(pathBytes)),
+ 0, // at_flags
+ uintptr(unsafe.Pointer(nameBytes)),
+ uintptr(unsafe.Pointer(&args)),
+ unsafe.Sizeof(args),
+ )
+ runtime.KeepAlive(pathBytes)
+ runtime.KeepAlive(nameBytes)
+ runtime.KeepAlive(&buf[0])
+ runtime.KeepAlive(&args)
+ if errno != 0 {
+ return fmt.Errorf("getxattrat: %w", errno)
+ }
+ if int(ret) != wantSize {
+ return fmt.Errorf("getxattrat returned %d, want %d", int(ret), wantSize)
+ }
+ return nil
+}
diff --git a/cmd/ioworkload/scenarios.go b/cmd/ioworkload/scenarios.go
index 3feb76c..b7ea44a 100644
--- a/cmd/ioworkload/scenarios.go
+++ b/cmd/ioworkload/scenarios.go
@@ -98,6 +98,7 @@ var scenarios = map[string]func() error{
"stat-enoent": statEnoent,
"stat-access-enoent": statAccessEnoent,
"stat-fstat-ebadf": statFstatEbadf,
+ "xattr-getxattrat": xattrGetxattrat,
"utime-basic": utimeBasic,
"utime-utimes": utimeUtimes,
"utime-enoent": utimeEnoent,
diff --git a/docs/syscall-tracing-plan.md b/docs/syscall-tracing-plan.md
index 9d36d8a..8cca6fd 100644
--- a/docs/syscall-tracing-plan.md
+++ b/docs/syscall-tracing-plan.md
@@ -92,7 +92,7 @@ sudo ./ior -trace-syscalls openat,recvmsg,nanosleep -no-trace-kinds null
Payload bytes classified by return value:
-- ReadClassified: `fgetxattr`, `flistxattr`, `getdents`, `getdents64`, `getrandom`, `getxattr`, `lgetxattr`, `listxattr`, `llistxattr`, `mq_timedreceive`, `msgrcv`, `pread64`, `preadv`, `preadv2`, `process_vm_readv`, `read`, `readlink`, `readlinkat`, `readv`, `recvfrom`, `recvmsg`, `syslog`
+- ReadClassified: `fgetxattr`, `flistxattr`, `getdents`, `getdents64`, `getrandom`, `getxattr`, `getxattrat`, `lgetxattr`, `listxattr`, `llistxattr`, `mq_timedreceive`, `msgrcv`, `pread64`, `preadv`, `preadv2`, `process_vm_readv`, `read`, `readlink`, `readlinkat`, `readv`, `recvfrom`, `recvmsg`, `syslog`
- TransferClassified: `copy_file_range`, `sendfile64`, `splice`, `tee`, `vmsplice`
- WriteClassified: `mq_timedsend`, `msgsnd`, `process_vm_writev`, `pwrite64`, `pwritev`, `pwritev2`, `sendmsg`, `sendto`, `write`, `writev`
diff --git a/integrationtests/xattr_test.go b/integrationtests/xattr_test.go
new file mode 100644
index 0000000..d36043c
--- /dev/null
+++ b/integrationtests/xattr_test.go
@@ -0,0 +1,37 @@
+package integrationtests
+
+import "testing"
+
+// xattrTraceArgs restricts tracing to the getxattrat tracepoints so the test is
+// not perturbed by unrelated xattr/open traffic.
+var xattrTraceArgs = []string{"-trace-syscalls", "getxattrat,setxattr,openat"}
+
+// TestXattrGetxattrat verifies ior traces getxattrat(2) (Linux 6.13+)
+// end-to-end. getxattrat takes a dirfd plus a real filesystem path at args[1]
+// (NOT args[0]=dfd) and an xattr NAME at args[3]; only the path must be
+// captured. The path is read on syscall entry, so enter_getxattrat must carry
+// the file path "xattrfile.txt" and never the xattr name "user.ior".
+func TestXattrGetxattrat(t *testing.T) {
+ result, _ := runScenarioResultWithIorArgs(t, "xattr-getxattrat", []ExpectedEvent{
+ {
+ PathContains: "xattrfile.txt",
+ Tracepoint: "enter_getxattrat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ }, xattrTraceArgs)
+
+ // The captured path must be the filesystem path, never the xattr name.
+ for _, rec := range result.Records {
+ if rec.TraceID.String() == "enter_getxattrat" && rec.Path == "user.ior" {
+ t.Errorf("getxattrat captured xattr name %q as path instead of file path", rec.Path)
+ }
+ }
+
+ // getxattrat returns the xattr value size in bytes; ior READ-classifies the
+ // exit, so the recorded byte count must reflect the 16-byte value written by
+ // the workload (consistent with getxattr/lgetxattr/fgetxattr).
+ exp := ExpectedEvent{Tracepoint: "enter_getxattrat", Comm: "ioworkload"}
+ assertEventBytesAtLeast(t, result, exp, uint64(len("getxattrat-value")))
+ assertEventDurationPositive(t, result, exp)
+}
diff --git a/internal/c/generated_tracepoints.c b/internal/c/generated_tracepoints.c
index 5c72813..8c76e7a 100644
--- a/internal/c/generated_tracepoints.c
+++ b/internal/c/generated_tracepoints.c
@@ -6372,7 +6372,7 @@ int handle_sys_enter_getxattrat(struct syscall_trace_enter *ctx) {
return 0;
}
-/// sys_exit_getxattrat is a struct ret_event (UNCLASSIFIED) (kind=ret)
+/// sys_exit_getxattrat is a struct ret_event (READ_CLASSIFIED) (kind=ret)
SEC("tracepoint/syscalls/sys_exit_getxattrat")
int handle_sys_exit_getxattrat(struct syscall_trace_exit *ctx) {
__u32 pid, tid;
@@ -6392,7 +6392,7 @@ int handle_sys_exit_getxattrat(struct syscall_trace_exit *ctx) {
ev->tid = tid;
ev->time = bpf_ktime_get_boot_ns();
ev->ret = ctx->ret;
- ev->ret_type = UNCLASSIFIED;
+ ev->ret_type = READ_CLASSIFIED;
bpf_ringbuf_submit(ev, 0);
return 0;
diff --git a/internal/c/generated_tracepoints_result.txt b/internal/c/generated_tracepoints_result.txt
index 971f92c..0842bda 100644
--- a/internal/c/generated_tracepoints_result.txt
+++ b/internal/c/generated_tracepoints_result.txt
@@ -474,7 +474,7 @@ sys_exit_gettid is a struct ret_event (UNCLASSIFIED) (kind=ret)
sys_exit_gettimeofday is a struct ret_event (UNCLASSIFIED) (kind=ret)
sys_exit_getuid is a struct ret_event (UNCLASSIFIED) (kind=ret)
sys_exit_getxattr is a struct ret_event (READ_CLASSIFIED) (kind=ret)
-sys_exit_getxattrat is a struct ret_event (UNCLASSIFIED) (kind=ret)
+sys_exit_getxattrat is a struct ret_event (READ_CLASSIFIED) (kind=ret)
sys_exit_init_module is a struct null_event (kind=module)
sys_exit_inotify_add_watch is a struct ret_event (UNCLASSIFIED) (kind=ret)
sys_exit_inotify_init is a struct eventfd_event (kind=eventfd)
diff --git a/internal/generate/classify.go b/internal/generate/classify.go
index f85cb93..3746bd9 100644
--- a/internal/generate/classify.go
+++ b/internal/generate/classify.go
@@ -588,6 +588,9 @@ var retClassifications = map[string]RetClassification{
"getdents": ReadClassified,
"getdents64": ReadClassified,
"getxattr": ReadClassified,
+ // getxattrat (Linux 6.13+) returns the size in bytes of the xattr value,
+ // exactly like getxattr/lgetxattr/fgetxattr, so it is a read byte-count.
+ "getxattrat": ReadClassified,
"lgetxattr": ReadClassified,
"listxattr": ReadClassified,
"llistxattr": ReadClassified,
diff --git a/internal/generate/retclassify_test.go b/internal/generate/retclassify_test.go
index 25c5e71..acd019b 100644
--- a/internal/generate/retclassify_test.go
+++ b/internal/generate/retclassify_test.go
@@ -5,6 +5,9 @@ import "testing"
func TestClassifyRetRead(t *testing.T) {
reads := []string{
"fgetxattr", "flistxattr", "getdents", "getdents64", "getxattr",
+ // getxattrat (Linux 6.13+) returns the xattr value size in bytes, the
+ // same read byte-count as getxattr/lgetxattr/fgetxattr.
+ "getxattrat",
"lgetxattr", "listxattr", "llistxattr", "pread64", "preadv",
"preadv2", "process_vm_readv", "read", "readlink", "readlinkat",
"readv", "recvmsg", "recvfrom", "syslog", "mq_timedreceive", "getrandom", "msgrcv",