Skip to content

Commit db7f932

Browse files
committed
feat(testrunner): //!-suppressions= directive for per-file valgrind silencers
1 parent 1ca866b commit db7f932

3 files changed

Lines changed: 116 additions & 19 deletions

File tree

main.go

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -524,18 +524,23 @@ func expandShellExprs(s string) string {
524524
}
525525

526526
// parseFileDirectives scans the leading lines of src for //! directives and
527-
// returns linker flags and C source files to compile in.
527+
// returns linker flags, C source files to compile in, and valgrind
528+
// suppression paths to apply when the test runs under --valgrind.
528529
//
529530
// //!-lm -> linker flag -lm
530531
// //!-lm [x86_64] -> linker flag -lm, x86_64 only
531532
// //!+helper.c -> compile helper.c alongside the module
532533
// //!+src/foo.c -- -DDEBUG -> compile src/foo.c with extra flag -DDEBUG
533534
// //!+src/foo.c [arch] -> compile only on matching arch
534535
// //!+src/foo.c [arch] -- FLAGS -> arch-specific file with extra flags
536+
// //!-suppressions=PATH -> pass --suppressions=PATH to valgrind
537+
// for this file (no effect outside --valgrind)
535538
//
536-
// srcDir is the directory of the .tin file; relative C source paths are
537-
// resolved against it. Scanning stops at the first non-comment, non-blank line.
538-
func parseFileDirectives(src, srcDir, stdlibDir string) (linkerFlags []string, cSources []cSource) {
539+
// srcDir is the directory of the .tin file; relative paths are resolved
540+
// against it. $TIN_RUNTIME / $TIN_STDLIB / $ENV variables expand in
541+
// suppression paths the same way they do in //!+file flags. Scanning
542+
// stops at the first non-comment, non-blank line.
543+
func parseFileDirectives(src, srcDir, stdlibDir string) (linkerFlags []string, cSources []cSource, vgSuppressions []string) {
539544
for _, line := range strings.SplitAfter(src, "\n") {
540545
trimmed := strings.TrimSpace(line)
541546
if trimmed == "" || strings.HasPrefix(trimmed, "//") && !strings.HasPrefix(trimmed, "//!") {
@@ -603,6 +608,27 @@ func parseFileDirectives(src, srcDir, stdlibDir string) (linkerFlags []string, c
603608
}
604609

605610
cSources = append(cSources, cSource{path: cpath, flags: extraFlags})
611+
} else if strings.HasPrefix(rest, "-suppressions=") {
612+
// Valgrind-only directive: register a suppressions file
613+
// that applies when the binary runs under --valgrind.
614+
// Honours the same `[arch]` qualifier as the other
615+
// directives so platform-specific suppressions stay
616+
// scoped (e.g. a glibc-only file is skipped on macOS).
617+
specAndQualifier, archQualifier := extractArchQualifier(strings.TrimPrefix(rest, "-suppressions="))
618+
if !archMatches(archQualifier) {
619+
continue
620+
}
621+
622+
rtDir := tinRuntimeDir()
623+
expanded := strings.ReplaceAll(specAndQualifier, "$TIN_RUNTIME", rtDir)
624+
expanded = strings.ReplaceAll(expanded, "$TIN_STDLIB", stdlibDir)
625+
expanded = os.ExpandEnv(expanded)
626+
627+
if !filepath.IsAbs(expanded) {
628+
expanded = filepath.Join(srcDir, expanded)
629+
}
630+
631+
vgSuppressions = append(vgSuppressions, expanded)
606632
} else {
607633
// Linker flag: check for optional arch qualifier.
608634
flagAndQualifier, archQualifier := extractArchQualifier(rest)
@@ -995,7 +1021,10 @@ doneFlags:
9951021
if _, statErr := os.Stat(runCacheBinPath); statErr == nil && sbomMatches(runCacheDir) {
9961022
memcheck, binArgs := parseRunArgs(fileArgIdx)
9971023
validateMemcheck(memcheck)
998-
execRunBinary(runCacheBinPath, memcheck, binArgs)
1024+
// Pick up `//!-suppressions=` from the source so cached
1025+
// single-file tests still hand them to valgrind.
1026+
_, _, vgSupps := parseFileDirectives(string(src), filepath.Dir(file), stdlibDirForDirectives(stdlibOverride))
1027+
execRunBinary(runCacheBinPath, memcheck, binArgs, vgSupps...)
9991028

10001029
return
10011030
}
@@ -1026,7 +1055,7 @@ doneFlags:
10261055
}
10271056

10281057
// Collect directives declared in the source file via //! lines
1029-
fileLinkerFlags, fileCSources := parseFileDirectives(string(src), filepath.Dir(file), stdlibDirForDirectives(stdlibOverride))
1058+
fileLinkerFlags, fileCSources, fileVgSuppressions := parseFileDirectives(string(src), filepath.Dir(file), stdlibDirForDirectives(stdlibOverride))
10301059

10311060
// Estimate total stages for progress display. Mirrors the actual
10321061
// step shape so the post-codegen setTotal call refines without
@@ -1238,7 +1267,7 @@ doneFlags:
12381267
continue
12391268
}
12401269

1241-
pkgLinkFlags, pkgCSources := parseFileDirectives(string(src), filepath.Dir(pkgSrc), stdlibDirForDirectives(stdlibOverride))
1270+
pkgLinkFlags, pkgCSources, _ := parseFileDirectives(string(src), filepath.Dir(pkgSrc), stdlibDirForDirectives(stdlibOverride))
12421271
fileLinkerFlags = append(fileLinkerFlags, pkgLinkFlags...)
12431272
fileCSources = append(fileCSources, pkgCSources...)
12441273
}
@@ -1437,7 +1466,7 @@ doneFlags:
14371466
cprog.clear()
14381467

14391468
validateMemcheck(memcheck)
1440-
execRunBinary(runCacheBinPath, memcheck, binArgs)
1469+
execRunBinary(runCacheBinPath, memcheck, binArgs, fileVgSuppressions...)
14411470

14421471
default:
14431472
_, _ = fmt.Fprint(os.Stderr, usage)
@@ -2668,16 +2697,30 @@ func validateMemcheck(memcheck string) {
26682697
// foreign-arch binaries can be exercised on the host. Modeled on Go's GOEXEC
26692698
// and Cargo's CARGO_TARGET_<TRIPLE>_RUNNER.
26702699
func memcheckCmd(memcheck, binary string, binArgs ...string) *exec.Cmd {
2700+
return memcheckCmdWithSuppressions(memcheck, binary, nil, binArgs...)
2701+
}
2702+
2703+
// memcheckCmdWithSuppressions is memcheckCmd plus an explicit list of
2704+
// valgrind --suppressions=PATH files. The test runner gathers these
2705+
// from `//!-suppressions=FILE` directives declared in the test source
2706+
// so the silence stays scoped to the file that opted in -- a global
2707+
// suppression set would silently hide leaks in unrelated tests.
2708+
func memcheckCmdWithSuppressions(memcheck, binary string, vgSuppressions []string, binArgs ...string) *exec.Cmd {
26712709
switch memcheck {
26722710
case "valgrind":
2673-
args := append([]string{
2711+
vgArgs := []string{
26742712
"--error-exitcode=1",
26752713
"--leak-check=full",
26762714
"--errors-for-leak-kinds=all",
2677-
binary,
2678-
}, binArgs...)
2715+
}
2716+
for _, s := range vgSuppressions {
2717+
vgArgs = append(vgArgs, "--suppressions="+s)
2718+
}
2719+
2720+
vgArgs = append(vgArgs, binary)
2721+
vgArgs = append(vgArgs, binArgs...)
26792722

2680-
return wrapExec("valgrind", args...)
2723+
return wrapExec("valgrind", vgArgs...)
26812724
case "leaks":
26822725
args := append([]string{"--atExit", "--", binary}, binArgs...)
26832726

@@ -2851,6 +2894,12 @@ func runFileTests(fpaths []string, extraFlags []string, extraCFlags []string, me
28512894
continue
28522895
}
28532896

2897+
// Parse //!-suppressions= directives up front so both the
2898+
// cache-hit and the fresh-compile branches below can hand them
2899+
// to memcheckCmdWithSuppressions; valgrind picks them up,
2900+
// non-valgrind runs just ignore the list.
2901+
_, _, fileVgSuppressions := parseFileDirectives(string(src), filepath.Dir(fpath), stdlibDirForDirectives(""))
2902+
28542903
// Cache lookup: if the test binary is already built and every dep
28552904
// recorded in its SBOM still hashes the same, run the cached binary
28562905
// directly and skip lex/parse/codegen for this file.
@@ -2860,7 +2909,7 @@ func runFileTests(fpaths []string, extraFlags []string, extraCFlags []string, me
28602909
if _, statErr := os.Stat(cachedBin); statErr == nil && sbomMatches(cacheDir) {
28612910
fmt.Printf("%s\n\n", fname)
28622911

2863-
run := memcheckCmd(memcheck, cachedBin)
2912+
run := memcheckCmdWithSuppressions(memcheck, cachedBin, fileVgSuppressions)
28642913

28652914
var outBuf bytes.Buffer
28662915

@@ -2994,21 +3043,24 @@ func runFileTests(fpaths []string, extraFlags []string, extraCFlags []string, me
29943043
continue // no test blocks in this file
29953044
}
29963045

2997-
fileLinks, fCSources := parseFileDirectives(string(src), filepath.Dir(fpath), stdlibDirForDirectives(""))
3046+
fileLinks, fCSources, _ := parseFileDirectives(string(src), filepath.Dir(fpath), stdlibDirForDirectives(""))
29983047

29993048
srcLinks := append([]string{}, fileLinks...)
30003049
for _, lib := range cg.LinkLibs() {
30013050
srcLinks = append(srcLinks, "-l"+lib)
30023051
}
30033052
// Collect //!+file.c and //!-lNAME directives from imported packages,
3004-
// just as the single-file build path does.
3053+
// just as the single-file build path does. --valgrind suppression
3054+
// directives stay scoped to the test file -- pulling them in from
3055+
// every transitive package would silence checks they didn't opt
3056+
// into.
30053057
for _, pkgSrc := range cg.PackageSrcPaths() {
30063058
pkgBytes, pkgReadErr := os.ReadFile(pkgSrc)
30073059
if pkgReadErr != nil {
30083060
continue
30093061
}
30103062

3011-
pkgLinks, pkgCSrcs := parseFileDirectives(string(pkgBytes), filepath.Dir(pkgSrc), stdlibDirForDirectives(""))
3063+
pkgLinks, pkgCSrcs, _ := parseFileDirectives(string(pkgBytes), filepath.Dir(pkgSrc), stdlibDirForDirectives(""))
30123064
srcLinks = append(srcLinks, pkgLinks...)
30133065
fCSources = append(fCSources, pkgCSrcs...)
30143066
}
@@ -3089,7 +3141,7 @@ func runFileTests(fpaths []string, extraFlags []string, extraCFlags []string, me
30893141
cprog.clear()
30903142
fmt.Printf("%s\n\n", fname)
30913143

3092-
run := memcheckCmd(memcheck, cachedBin)
3144+
run := memcheckCmdWithSuppressions(memcheck, cachedBin, fileVgSuppressions)
30933145

30943146
var outBuf bytes.Buffer
30953147

@@ -3623,8 +3675,8 @@ func collectExtraObjs(fileArgIdx int) []string {
36233675
}
36243676

36253677
// execRunBinary runs `bin` (under memcheck if set) and exits with its status.
3626-
func execRunBinary(bin, memcheck string, binArgs []string) {
3627-
run := memcheckCmd(memcheck, bin, binArgs...)
3678+
func execRunBinary(bin, memcheck string, binArgs []string, vgSuppressions ...string) {
3679+
run := memcheckCmdWithSuppressions(memcheck, bin, vgSuppressions, binArgs...)
36283680
run.Stdout = os.Stdout
36293681
run.Stderr = os.Stderr
36303682

stdlib/net/dns/dns_test.tin

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1+
//!-suppressions=valgrind-glibc-nss.supp [linux]
12
// stdlib/net/dns/dns_test.tin -- coverage for net::dns.
23
//
34
// lookup_host hits the real resolver, so we test against `localhost`
45
// which every POSIX system resolves locally (127.0.0.1 and ::1) and
56
// against a guaranteed-invalid label to exercise the error path.
7+
//
8+
// The `//!-suppressions=` directive above (linux-only) silences the 21
9+
// "still reachable" blocks glibc's lazy nss/resolver dlopen leaves
10+
// mapped for the process lifetime when getaddrinfo() runs for the
11+
// first time. Pure-C reproducer with the exact same byte/block count
12+
// proves they originate entirely in glibc; the suppression rules in
13+
// the file are anchored at `_dl_open` so real leaks elsewhere are
14+
// not affected.
615

716
use assert
817
use net::dns
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# runtime/valgrind-glibc-nss.supp -- valgrind suppressions for glibc's
2+
# lazy nss / resolver module loading.
3+
#
4+
# The first getaddrinfo() (and similar nss callers) in a glibc process
5+
# loads libnss_files.so / libnss_dns.so / libresolv.so via
6+
# __libc_dlopen_mode -> _dl_open and keeps the resulting mappings live
7+
# for the program's lifetime. The rtld bookkeeping for those .so's
8+
# (linkmap entries, version maps, scope arrays, cache strdup'd paths)
9+
# stays in glibc's globals with no dlclose() ever called -- valgrind
10+
# reports each as "still reachable".
11+
#
12+
# Verified by a pure-C reproducer that calls only getaddrinfo() +
13+
# freeaddrinfo() with zero Tin code on the stack: 9,816 bytes / 21
14+
# blocks / 8 records, byte-for-byte identical to what Tin's dns_test
15+
# shows. Every record's call chain terminates at `_dl_open` (the
16+
# dynamic linker's open-and-map entry), so we anchor the match there.
17+
# Real reachable leaks elsewhere in the program (no `_dl_open` frame
18+
# on the stack) still surface.
19+
20+
{
21+
glibc-rtld-dlopen-malloc-reachable
22+
Memcheck:Leak
23+
match-leak-kinds: reachable
24+
fun:malloc
25+
...
26+
fun:_dl_open
27+
}
28+
29+
{
30+
glibc-rtld-dlopen-calloc-reachable
31+
Memcheck:Leak
32+
match-leak-kinds: reachable
33+
fun:calloc
34+
...
35+
fun:_dl_open
36+
}

0 commit comments

Comments
 (0)