summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-31 19:09:16 +0300
committerPaul Buetow <paul@buetow.org>2026-05-31 19:09:16 +0300
commit8a1bf6236f6a525881c647fd881093b393436411 (patch)
tree0fb537b847b6e5c72d2d3d98d7ce8fbc15330102
parentc3177bd82c16429c1bb246d19af76012479f0c01 (diff)
listxattrat: READ-classify return for xattr-list family consistency
listxattrat(2) (Linux 6.13+) returns the size in bytes of the list of extended attribute names, exactly like listxattr/llistxattr/flistxattr, 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). This mirrors the getxattrat fix (task ku, commit c3177bd) and completes xattr-family consistency: get-family and list-family are READ_CLASSIFIED while set-family and remove-family stay UNCLASSIFIED (they return 0/-1). Update the docs ReadClassified list and the retclassify expectation, and add an ioworkload scenario plus integration test: the workload sets a user xattr then lists names via the raw listxattrat(2) syscall with AT_FDCWD, and the test asserts enter_listxattrat captures the file path and accounts the returned name-list size as read bytes. Task: r20 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rw-r--r--cmd/ioworkload/scenario_xattr.go76
-rw-r--r--cmd/ioworkload/scenarios.go1
-rw-r--r--docs/syscall-tracing-plan.md2
-rw-r--r--integrationtests/xattr_test.go27
-rw-r--r--internal/c/generated_tracepoints.c4
-rw-r--r--internal/c/generated_tracepoints_result.txt2
-rw-r--r--internal/generate/classify.go4
-rw-r--r--internal/generate/retclassify_test.go6
8 files changed, 117 insertions, 5 deletions
diff --git a/cmd/ioworkload/scenario_xattr.go b/cmd/ioworkload/scenario_xattr.go
index 42c679f..f974c01 100644
--- a/cmd/ioworkload/scenario_xattr.go
+++ b/cmd/ioworkload/scenario_xattr.go
@@ -21,6 +21,19 @@ import (
// or -1 on error.
const sysGetxattrat = 464
+// listxattrat is syscall number 465 on amd64 (added in Linux 6.13, right after
+// getxattrat). Go's syscall package does not export SYS_LISTXATTRAT, so we
+// invoke it by its raw number. Its signature is:
+//
+// listxattrat(int dfd, const char *pathname, unsigned int at_flags,
+// char *list, size_t size)
+//
+// The filesystem PATH is at args[1] (after the dirfd); args[3] is the userspace
+// buffer that receives the NUL-separated list of xattr names. The syscall
+// returns the size in bytes of the name list (a read byte-count), or -1 on
+// error — exactly like listxattr/llistxattr/flistxattr.
+const sysListxattrat = 465
+
// 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 {
@@ -105,3 +118,66 @@ func callGetxattrat(path, name string, wantSize int) error {
}
return nil
}
+
+// xattrListxattrat creates a file on tmpfs (/tmp), sets a user xattr on it, then
+// lists that file's xattr names via the raw listxattrat(2) syscall with
+// AT_FDCWD. This exercises ior's listxattrat tracing end-to-end and confirms:
+// - the real filesystem path (args[1]) is captured, NOT the dirfd;
+// - the syscall exit is READ-classified so the returned name-list size is
+// accounted as read bytes, consistent with listxattr/llistxattr/flistxattr.
+func xattrListxattrat() error {
+ dir, cleanup, err := makeTempDir("xattr-listxattrat")
+ 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"
+ if err := syscall.Setxattr(path, xattrName, []byte("listxattrat-value"), 0); err != nil {
+ return fmt.Errorf("setxattr: %w", err)
+ }
+
+ // The returned list is the NUL-terminated xattr name, e.g. "user.ior\0".
+ return callListxattrat(path, len(xattrName)+1)
+}
+
+// callListxattrat performs the raw listxattrat(AT_FDCWD, path, 0, list,
+// sizeof(list)) call and verifies it returns at least the expected list size.
+// The kernel may report additional system xattr names (e.g. "security.*"), so
+// we assert the returned size is at least wantMinSize rather than exact.
+func callListxattrat(path string, wantMinSize int) error {
+ pathBytes, err := syscall.BytePtrFromString(path)
+ if err != nil {
+ return fmt.Errorf("path bytes: %w", err)
+ }
+
+ list := make([]byte, 256)
+ // 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(
+ sysListxattrat,
+ uintptr(dirfd),
+ uintptr(unsafe.Pointer(pathBytes)),
+ 0, // at_flags
+ uintptr(unsafe.Pointer(&list[0])),
+ uintptr(len(list)),
+ 0,
+ )
+ runtime.KeepAlive(pathBytes)
+ runtime.KeepAlive(&list[0])
+ if errno != 0 {
+ return fmt.Errorf("listxattrat: %w", errno)
+ }
+ if int(ret) < wantMinSize {
+ return fmt.Errorf("listxattrat returned %d, want at least %d", int(ret), wantMinSize)
+ }
+ return nil
+}
diff --git a/cmd/ioworkload/scenarios.go b/cmd/ioworkload/scenarios.go
index b7ea44a..6503db7 100644
--- a/cmd/ioworkload/scenarios.go
+++ b/cmd/ioworkload/scenarios.go
@@ -99,6 +99,7 @@ var scenarios = map[string]func() error{
"stat-access-enoent": statAccessEnoent,
"stat-fstat-ebadf": statFstatEbadf,
"xattr-getxattrat": xattrGetxattrat,
+ "xattr-listxattrat": xattrListxattrat,
"utime-basic": utimeBasic,
"utime-utimes": utimeUtimes,
"utime-enoent": utimeEnoent,
diff --git a/docs/syscall-tracing-plan.md b/docs/syscall-tracing-plan.md
index 8cca6fd..71890ae 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`, `getxattrat`, `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`, `listxattrat`, `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
index d36043c..2ba30ac 100644
--- a/integrationtests/xattr_test.go
+++ b/integrationtests/xattr_test.go
@@ -6,6 +6,10 @@ import "testing"
// not perturbed by unrelated xattr/open traffic.
var xattrTraceArgs = []string{"-trace-syscalls", "getxattrat,setxattr,openat"}
+// xattrListTraceArgs restricts tracing to the listxattrat tracepoints so the
+// test is not perturbed by unrelated xattr/open traffic.
+var xattrListTraceArgs = []string{"-trace-syscalls", "listxattrat,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
@@ -35,3 +39,26 @@ func TestXattrGetxattrat(t *testing.T) {
assertEventBytesAtLeast(t, result, exp, uint64(len("getxattrat-value")))
assertEventDurationPositive(t, result, exp)
}
+
+// TestXattrListxattrat verifies ior traces listxattrat(2) (Linux 6.13+)
+// end-to-end. listxattrat takes a dirfd plus a real filesystem path at args[1]
+// (NOT args[0]=dfd); only the path must be captured. The path is read on
+// syscall entry, so enter_listxattrat must carry the file path "xattrfile.txt".
+func TestXattrListxattrat(t *testing.T) {
+ result, _ := runScenarioResultWithIorArgs(t, "xattr-listxattrat", []ExpectedEvent{
+ {
+ PathContains: "xattrfile.txt",
+ Tracepoint: "enter_listxattrat",
+ Comm: "ioworkload",
+ MinCount: 1,
+ },
+ }, xattrListTraceArgs)
+
+ // listxattrat returns the size in bytes of the xattr name list; ior
+ // READ-classifies the exit, so the recorded byte count must reflect at least
+ // the NUL-terminated "user.ior" name set by the workload (consistent with
+ // listxattr/llistxattr/flistxattr).
+ exp := ExpectedEvent{Tracepoint: "enter_listxattrat", Comm: "ioworkload"}
+ assertEventBytesAtLeast(t, result, exp, uint64(len("user.ior")+1))
+ assertEventDurationPositive(t, result, exp)
+}
diff --git a/internal/c/generated_tracepoints.c b/internal/c/generated_tracepoints.c
index 8c76e7a..64d40db 100644
--- a/internal/c/generated_tracepoints.c
+++ b/internal/c/generated_tracepoints.c
@@ -6579,7 +6579,7 @@ int handle_sys_enter_listxattrat(struct syscall_trace_enter *ctx) {
return 0;
}
-/// sys_exit_listxattrat is a struct ret_event (UNCLASSIFIED) (kind=ret)
+/// sys_exit_listxattrat is a struct ret_event (READ_CLASSIFIED) (kind=ret)
SEC("tracepoint/syscalls/sys_exit_listxattrat")
int handle_sys_exit_listxattrat(struct syscall_trace_exit *ctx) {
__u32 pid, tid;
@@ -6599,7 +6599,7 @@ int handle_sys_exit_listxattrat(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 0842bda..e6cb4d3 100644
--- a/internal/c/generated_tracepoints_result.txt
+++ b/internal/c/generated_tracepoints_result.txt
@@ -510,7 +510,7 @@ sys_exit_listen is a struct ret_event (UNCLASSIFIED) (kind=ret)
sys_exit_listmount is a struct ret_event (UNCLASSIFIED) (kind=ret)
sys_exit_listns is a struct ret_event (UNCLASSIFIED) (kind=ret)
sys_exit_listxattr is a struct ret_event (READ_CLASSIFIED) (kind=ret)
-sys_exit_listxattrat is a struct ret_event (UNCLASSIFIED) (kind=ret)
+sys_exit_listxattrat is a struct ret_event (READ_CLASSIFIED) (kind=ret)
sys_exit_llistxattr is a struct ret_event (READ_CLASSIFIED) (kind=ret)
sys_exit_lremovexattr is a struct ret_event (UNCLASSIFIED) (kind=ret)
sys_exit_lseek is a struct ret_event (UNCLASSIFIED) (kind=ret)
diff --git a/internal/generate/classify.go b/internal/generate/classify.go
index 3746bd9..0735291 100644
--- a/internal/generate/classify.go
+++ b/internal/generate/classify.go
@@ -593,6 +593,10 @@ var retClassifications = map[string]RetClassification{
"getxattrat": ReadClassified,
"lgetxattr": ReadClassified,
"listxattr": ReadClassified,
+ // listxattrat (Linux 6.13+) returns the size in bytes of the list of
+ // extended attribute names, exactly like listxattr/llistxattr/flistxattr,
+ // so it is a read byte-count.
+ "listxattrat": ReadClassified,
"llistxattr": ReadClassified,
"pread64": ReadClassified,
"preadv": ReadClassified,
diff --git a/internal/generate/retclassify_test.go b/internal/generate/retclassify_test.go
index acd019b..4fc7501 100644
--- a/internal/generate/retclassify_test.go
+++ b/internal/generate/retclassify_test.go
@@ -8,7 +8,11 @@ func TestClassifyRetRead(t *testing.T) {
// 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",
+ "lgetxattr", "listxattr",
+ // listxattrat (Linux 6.13+) returns the size in bytes of the xattr
+ // name list, the same read byte-count as listxattr/llistxattr/flistxattr.
+ "listxattrat",
+ "llistxattr", "pread64", "preadv",
"preadv2", "process_vm_readv", "read", "readlink", "readlinkat",
"readv", "recvmsg", "recvfrom", "syslog", "mq_timedreceive", "getrandom", "msgrcv",
}