diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-30 10:35:59 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-30 10:35:59 +0300 |
| commit | edf4f5d586a65ce6258503e453c98b07429d82d1 (patch) | |
| tree | 9ac7f49136fa20cd172c16f95f7cbff91202099f | |
| parent | 7595c52029ddd83bdcc48481528f2af7c4ccb1a0 (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.go | 77 | ||||
| -rw-r--r-- | internal/generate/family_test.go | 14 |
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 |
