summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-30 10:35:59 +0300
committerPaul Buetow <paul@buetow.org>2026-05-30 10:35:59 +0300
commitedf4f5d586a65ce6258503e453c98b07429d82d1 (patch)
tree9ac7f49136fa20cd172c16f95f7cbff91202099f
parent7595c52029ddd83bdcc48481528f2af7c4ccb1a0 (diff)
test(generate): lock in tkill classification (Signals/null/UNCLASSIFIED)
Audit of tkill(2) (task 310) confirmed correct tracing: tkill(tid, sig) is FamilySignals, kind=null, ret UNCLASSIFIED, matching its siblings kill/tgkill/rt_sig*. tkill/tgkill are intentionally absent from the name-only kind table; ClassifyFormat returns KindNone for them (the pid_t tid is not matched by the fd rule, so the thread id is never misread as a file descriptor) and classifyEnterForGeneration promotes that to KindNull at generation time. This was untested, so add lock-in coverage closing the gap: - TestGenerateTkillHandler: enter emits null_event, captures no arg (tid is not an fd), exit reports raw status as UNCLASSIFIED. - TestClassifyTkillFallsThroughToNull: pins ClassifyFormat=KindNone and the KindNull generation fallback, so a future fd-rule regression fails. - TestClassifyRetTkillUnclassified: 0/-1 status is not a byte count. - Extend TestClassifySyscallFamily with kill/tkill/tgkill (enter+exit) so a stray reclassification out of FamilySignals trips the test. No generated output or runtime behavior changed (mage generate clean). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rw-r--r--internal/generate/codegen_test.go77
-rw-r--r--internal/generate/family_test.go14
2 files changed, 91 insertions, 0 deletions
diff --git a/internal/generate/codegen_test.go b/internal/generate/codegen_test.go
index 7f9c223..bb06418 100644
--- a/internal/generate/codegen_test.go
+++ b/internal/generate/codegen_test.go
@@ -411,6 +411,83 @@ func TestClassifyRetSigaltstackUnclassified(t *testing.T) {
}
}
+// TestGenerateTkillHandler locks in how tkill(2) is generated. Per the man page:
+//
+// int tkill(pid_t tid, int sig)
+//
+// tkill sends signal sig to the thread whose thread id is tid; it is the
+// obsolete predecessor of tgkill(tgid, tid, sig) (the kernel recommends tgkill
+// because a bare tid can be recycled). It returns 0 on success or -1 on error.
+// Neither argument is an fd or a path: args[0] is a thread id (a signal target,
+// NOT a file descriptor) and args[1] is the signal number. tkill is not listed
+// in the name-only kind table; it carries fields named "pid"/"sig" that match no
+// fd/path/name pattern, so ClassifyFormat returns KindNone and the generation
+// fallback (classifyEnterForGeneration) promotes it to KindNull. This test guards
+// that path: tkill must emit a struct null_event and the args[0] tid must never be
+// captured as an fd. Consequently:
+// - The enter handler emits a struct null_event and must NOT capture any arg —
+// in particular the tid must not be mistaken for an fd.
+// - The exit handler reports the raw int status as UNCLASSIFIED; the 0/-1
+// return is not a byte count, so it must never be tagged READ/WRITE/TRANSFER.
+func TestGenerateTkillHandler(t *testing.T) {
+ // syntheticPair derives the fixture from FormatKill, whose fields
+ // (pid_t pid; int sig) match the real sys_enter_tkill tracepoint layout
+ // exactly, so this exercises tkill's true argument shape.
+ output := GenerateTracepointsC(mustParseAll(t, syntheticPair("tkill")))
+
+ enterSec := `SEC("tracepoint/syscalls/sys_enter_tkill")`
+ exitSec := `SEC("tracepoint/syscalls/sys_exit_tkill")`
+ requireContains(t, output, enterSec)
+ requireContains(t, output, "struct null_event *ev")
+ requireContains(t, output, "ev->event_type = ENTER_NULL_EVENT;")
+ requireContains(t, output, "ev->trace_id = SYS_ENTER_TKILL;")
+
+ // The KindNull enter handler must not wire the tid (args[0]) or sig (args[1])
+ // as an fd/path/addr — the tid is a signal target, not a traced I/O resource.
+ // Scope to the enter handler body (from the enter SEC up to the exit SEC).
+ enterStart := strings.Index(output, enterSec)
+ exitStart := strings.Index(output, exitSec)
+ if enterStart < 0 || exitStart < 0 || exitStart <= enterStart {
+ t.Fatalf("tkill: handlers not found in expected order")
+ }
+ enterBody := output[enterStart:exitStart]
+ if strings.Contains(enterBody, "ctx->args[") {
+ t.Error("tkill must be KindNull: enter handler must not capture any arg (tid is not an fd)")
+ }
+
+ // The exit handler reports the raw 0/-1 status as UNCLASSIFIED, not a byte count.
+ requireContains(t, output, exitSec)
+ requireContains(t, output, "ev->ret = ctx->ret;")
+ requireContains(t, output, "ev->ret_type = UNCLASSIFIED;")
+}
+
+// TestClassifyTkillFallsThroughToNull pins the classifier behaviour that makes
+// tkill safe: ClassifyFormat itself returns KindNone (no field matches an
+// fd/path/name pattern — crucially the pid_t tid field is NOT treated as an fd),
+// and only the generation-time fallback turns that into KindNull. If a future
+// change made the tid match the fd rule, this test would flip to KindFd and fail.
+func TestClassifyTkillFallsThroughToNull(t *testing.T) {
+ f := mustParseOne(t, strings.Replace(
+ strings.Replace(FormatKill, "sys_enter_kill", "sys_enter_tkill", 1),
+ "ID: 183", "ID: 177", 1))
+ if r := ClassifyFormat(&f); r.Kind != KindNone {
+ t.Errorf("tkill ClassifyFormat = %d, want KindNone (tid must not match the fd rule)", r.Kind)
+ }
+ if r := classifyEnterForGeneration(&f); r.Kind != KindNull {
+ t.Errorf("tkill classifyEnterForGeneration = %d, want KindNull", r.Kind)
+ }
+}
+
+// TestClassifyRetTkillUnclassified locks in that tkill's return value is
+// UNCLASSIFIED. It returns 0 on success or -1 on error — a status code, not a
+// number of bytes transferred — so classifying it as READ/WRITE/TRANSFER would
+// wrongly count it as data movement.
+func TestClassifyRetTkillUnclassified(t *testing.T) {
+ if got := ClassifyRet("sys_exit_tkill"); got != Unclassified {
+ t.Errorf("tkill ret classification = %q, want %q", got, Unclassified)
+ }
+}
+
// TestGenerateSysinfoHandler locks in how sysinfo(2) is generated. Per the man
// page:
//
diff --git a/internal/generate/family_test.go b/internal/generate/family_test.go
index 7302919..4bff095 100644
--- a/internal/generate/family_test.go
+++ b/internal/generate/family_test.go
@@ -197,6 +197,20 @@ func TestClassifySyscallFamily(t *testing.T) {
{"sys_enter_rt_sigqueueinfo", FamilySignals},
{"sys_enter_rt_tgsigqueueinfo", FamilySignals},
{"sys_enter_sigaltstack", FamilySignals},
+ // tkill(tid, sig) and its successor tgkill(tgid, tid, sig) deliver a signal
+ // to a specific thread; kill(pid, sig) signals a whole process. All three
+ // are signal-delivery syscalls and belong in FamilySignals with the rest of
+ // the group above. tkill is the obsolete predecessor of tgkill (man 2 tkill)
+ // and must not drift into FamilyProcess just because its first arg is a
+ // thread id — the tid is a signal target, not a process-control operand.
+ // Assert both enter and exit for tkill/tgkill/kill so a stray
+ // reclassification of any of them trips this test.
+ {"sys_enter_kill", FamilySignals},
+ {"sys_exit_kill", FamilySignals},
+ {"sys_enter_tkill", FamilySignals},
+ {"sys_exit_tkill", FamilySignals},
+ {"sys_enter_tgkill", FamilySignals},
+ {"sys_exit_tgkill", FamilySignals},
// ioprio_get/ioprio_set query/set the I/O scheduling class and priority of
// a process, process group, or user. They are the I/O-priority analogues of
// getpriority/setpriority (the CPU nice value) and share the identical