Skip to content

Commit eaf3647

Browse files
ci: add Valgrind memcheck job
- Add valgrind-memcheck job running full pytest suite under memcheck with continue-on-error: true - Uses --error-exitcode=99 with definite/indirect leak checking - Currently finding real sshfs-owned leaks (buf_init/sftp_read, buf_init/sftp_send_iov, cache_add_link) — will go green once those are fixed - Raw Valgrind logs written via --log-file and uploaded as artifacts alongside JUnit XML - Add VALGRIND_OPTIONS support to test/util.py via shlex.split - Suppression file covers only third-party libfuse worker-thread teardown leaks - Fix fusermount/fusermount3 mismatch, scale timeouts 4x under Valgrind - Set G_DEBUG/G_SLICE for cleaner Valgrind output - Hard-fail FUSE preflight, workflow-level permissions and concurrency - All actions pinned to Node 24-capable SHAs, runner pinned to ubuntu-24.04
1 parent afbdf92 commit eaf3647

3 files changed

Lines changed: 58 additions & 6 deletions

File tree

test/conftest.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sys
2+
import os
23
import pytest
34
import time
45
import re
@@ -85,6 +86,13 @@ def register_output(self, pattern, count=1, flags=re.MULTILINE):
8586
current_capfd = None
8687

8788

89+
_running_with_valgrind = os.environ.get("TEST_WITH_VALGRIND", "no").lower().strip() not in (
90+
"no",
91+
"false",
92+
"0",
93+
)
94+
95+
8896
@pytest.fixture(autouse=True)
8997
def save_cap_fixtures(request, capfd):
9098
global current_capfd
@@ -93,6 +101,12 @@ def save_cap_fixtures(request, capfd):
93101
# Monkeypatch in a function to register false positives
94102
type(capfd).register_output = register_output
95103

104+
# When running under Valgrind, its ==pid== summary lines on stderr are
105+
# expected. Register them as false positives so check_test_output does
106+
# not mistake them for suspicious output.
107+
if _running_with_valgrind:
108+
capfd.false_positives.append((r"^==[0-9]+==[^\n]*\n", re.MULTILINE, 0))
109+
96110
if request.config.getoption("capture") == "no":
97111
capfd = None
98112
current_capfd = capfd

test/util.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99

1010
basename = pjoin(os.path.dirname(__file__), "..")
1111

12+
_valgrind_timeout_multiplier = (
13+
4
14+
if os.environ.get("TEST_WITH_VALGRIND", "no").lower().strip()
15+
not in ("no", "false", "0")
16+
else 1
17+
)
18+
_mount_timeout = 30 * _valgrind_timeout_multiplier
19+
1220

1321
def os_create(name):
1422
os.close(os.open(name, os.O_CREAT | os.O_RDWR))
@@ -25,7 +33,7 @@ def os_open(name, flags):
2533

2634
def wait_for_mount(mount_process, mnt_dir, test_fn=os.path.ismount):
2735
elapsed = 0
28-
while elapsed < 30:
36+
while elapsed < _mount_timeout:
2937
if test_fn(mnt_dir):
3038
return True
3139
if mount_process.poll() is not None:
@@ -37,7 +45,7 @@ def wait_for_mount(mount_process, mnt_dir, test_fn=os.path.ismount):
3745

3846
def cleanup(mount_process, mnt_dir):
3947
subprocess.call(
40-
["fusermount", "-z", "-u", mnt_dir],
48+
["fusermount3", "-z", "-u", mnt_dir],
4149
stdout=subprocess.DEVNULL,
4250
stderr=subprocess.STDOUT,
4351
)
@@ -55,7 +63,7 @@ def umount(mount_process, mnt_dir):
5563
# Give mount process a little while to terminate. Popen.wait(timeout)
5664
# was only added in 3.3...
5765
elapsed = 0
58-
while elapsed < 30:
66+
while elapsed < _mount_timeout:
5967
code = mount_process.poll()
6068
if code is not None:
6169
if code == 0:
@@ -93,12 +101,12 @@ def skip(reason: str):
93101
return pytest.mark.skip(reason=reason)
94102

95103
with subprocess.Popen(
96-
["which", "fusermount"], stdout=subprocess.PIPE, universal_newlines=True
104+
["which", "fusermount3"], stdout=subprocess.PIPE, universal_newlines=True
97105
) as which:
98106
fusermount_path = which.communicate()[0].strip()
99107

100108
if not fusermount_path or which.returncode != 0:
101-
return skip("Can't find fusermount executable")
109+
return skip("Can't find fusermount3 executable")
102110

103111
if not os.path.exists("/dev/fuse"):
104112
return skip("FUSE kernel module does not seem to be loaded")
@@ -126,6 +134,11 @@ def skip(reason: str):
126134
"false",
127135
"0",
128136
):
129-
base_cmdline = ["valgrind", "-q", "--"]
137+
import shlex
138+
valgrind_options_env = os.environ.get("VALGRIND_OPTIONS", "")
139+
if valgrind_options_env:
140+
base_cmdline = ["valgrind"] + shlex.split(valgrind_options_env) + ["--"]
141+
else:
142+
base_cmdline = ["valgrind", "-q", "--"]
130143
else:
131144
base_cmdline = []

test/valgrind.supp

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Valgrind suppression file for sshfs tests.
2+
#
3+
# Keep this file minimal. Only suppress stacks that originate entirely in
4+
# third-party libraries (GLib, libfuse, glibc, pthreads) and have been
5+
# confirmed as false positives or benign teardown noise.
6+
#
7+
# Do NOT suppress any stack frame that includes sshfs.c or cache.c unless
8+
# there is a documented upstream false positive with a linked note below.
9+
#
10+
# To generate candidates locally:
11+
# TEST_WITH_VALGRIND=true VALGRIND_OPTIONS="--tool=memcheck --leak-check=full \
12+
# --gen-suppressions=all -q --" python3 -m pytest test/test_sshfs.py ...
13+
14+
# libfuse allocates thread-local or worker-thread state inside its shared
15+
# library during pthread_create. These are not reachable after the threads
16+
# exit but are never explicitly freed — they are benign teardown leaks in
17+
# libfuse internals, not sshfs bugs.
18+
{
19+
libfuse-worker-thread-alloc
20+
Memcheck:Leak
21+
match-leak-kinds: definite,indirect
22+
fun:calloc
23+
...
24+
obj:*/libfuse3.so*
25+
}

0 commit comments

Comments
 (0)