Skip to content

Commit 8db5cda

Browse files
committed
ci: record the native side of the Windows coverage crash
faulthandler stops exactly where the answer starts. The fault is inside an extension module's initialiser, so the frames that matter are the ones it cannot reach, and every report ends `<cannot get C stack on this system>`. Every ordinary way to see past that is closed on this runner. cdb is not installed: the SDK is present without the Debugging Tools feature, so Windows Kits ships the debugger DLLs and none of the executables. procdump would mean an outbound download on a runner whose agent exists to police outbound requests. A postmortem debugger under AeDebug never fires, because this image does not record user-process crashes with WER, which is why the crash-dumps directory came back empty when that was tried. And there are no PDBs in any case, since build_hooks.py refuses debuggable builds on Windows. dbgcore.dll in System32 is always there though, and exports MiniDumpWriteDump, so the process reports on itself. A vectored handler writes the exception record, the faulting address resolved to module + RVA, the native return chain resolved the same way, the module map, and a minidump, then returns EXCEPTION_CONTINUE_SEARCH so the process still dies with the exit code CI would have seen. Symbols are absent, but which binary faulted is the open question and module + RVA answers it. crash_probe.py drives four runs. A selftest faults on purpose, so a clean report proves the handler works here and an empty one is never mistaken for "nothing crashed". A run under coverage reproduces the nightly's own conditions on the 8 MB stack the pytest wrapper uses. A run with a bare trace function instead of coverage arms Cython's linetrace the same way, so a fault there means any tracer will do rather than coverage specifically, and the last line of its breadcrumb names the .pyx statement that was executing. env_fingerprint.py records the environment around the import -- driver, CPU, and the loaded-module map -- which is what comparing against a machine that does not fault requires. This replaces the per-submodule probes, which measured nothing: importing cuda.core._resource_handles executes cuda/core/__init__.py on the way in, so all six walked the same path and all six failed identically. Verified on two Windows runners that do not fault, where the selftest produced a resolved address, a 44-frame backtrace and a 300 KB dump, and the breadcrumb recorded .pyx line numbers through to the end of the import.
1 parent ac68206 commit 8db5cda

4 files changed

Lines changed: 953 additions & 38 deletions

File tree

.github/workflows/coverage.yml

Lines changed: 80 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -387,54 +387,88 @@ jobs:
387387
--cov-config="$GITHUB_WORKSPACE/.coveragerc" \
388388
"$GITHUB_WORKSPACE/cuda_bindings/tests"
389389
390-
# Import tracing put the fault in cuda.core._event, reached through
391-
# _stream from _context. These probes strip everything that is not
392-
# needed to see it: no pytest, no conftest, no tests -- one fresh
393-
# interpreter per module, importing exactly one name under coverage,
394-
# since the linetrace wheel and the ctracer are the two conditions
395-
# `test-wheel-windows.yml` lacks on this same runner.
390+
# The whole crash reduces to one `import cuda.core` under coverage, with
391+
# no pytest, no conftest and no tests: the linetrace wheel and the
392+
# ctracer are the two conditions `test-wheel-windows.yml` lacks on this
393+
# same runner. Importing submodules one at a time was tried first and
394+
# measured nothing -- every one of them executes cuda/core/__init__.py
395+
# on the way in, so all six probes walked the same path and all six
396+
# failed identically.
396397
#
397-
# The order runs leaf-first, so the first line that fails to print
398-
# `PROBE OK` is the smallest unit that reproduces. Importing _event
399-
# directly also enters the _context/_stream/_event cimport cycle from
400-
# the opposite side, which says whether the direction matters.
398+
# What is missing is the native half. The fault happens inside an
399+
# extension module's initialiser, and faulthandler's report ends at
400+
# `<cannot get C stack on this system>`. The usual ways to see past
401+
# that are all closed here: cdb is not installed (the SDK ships without
402+
# the Debugging Tools feature), procdump would need an outbound
403+
# download, a postmortem debugger under AeDebug never fires because this
404+
# image does not record user-process crashes with WER, and there are no
405+
# PDBs because build_hooks.py refuses debuggable builds on Windows.
401406
#
402-
# Placed here rather than before the other suites because this is the
403-
# machine state the crash is known to occur in; a clean-machine
404-
# placement is the follow-up if these come back clean.
407+
# dbgcore.dll in System32 is always present though, so the process
408+
# reports on itself. ci/tools/native_crash_handler.py catches the fault
409+
# first-chance and writes the faulting address resolved to module + RVA,
410+
# the native return chain resolved the same way, the module map, and a
411+
# minidump, then lets the process die exactly as it would have. Which
412+
# binary faults is the open question, and module + RVA answers it
413+
# without symbols.
405414
#
406-
# COVERAGE_FILE keeps the probes out of the real .coverage, which
415+
# Four runs, each answering something the others cannot:
416+
#
417+
# selftest faults on purpose, so a clean report proves the handler
418+
# works here and an empty one is not mistaken for "no
419+
# crash"
420+
# coverage the nightly's own conditions, on the 8 MB stack the
421+
# pytest wrapper uses
422+
# breadcrumb the same import with a bare trace function instead of
423+
# coverage. It arms Cython's linetrace the same way, so
424+
# a crash here means any tracer will do rather than
425+
# coverage specifically -- and the last line of its log
426+
# names the .pyx statement that was executing
427+
# fingerprint the environment around the import: driver, CPU, and
428+
# the loaded-module map, which is what a comparison
429+
# against a machine that does not crash needs
430+
#
431+
# COVERAGE_FILE keeps all of this out of the real .coverage, which
407432
# `coverage run` would otherwise truncate.
408-
- name: Probe which module import faults
433+
- name: Record the native side of the fault
409434
if: always()
410435
continue-on-error: true
411436
env:
412437
COVERAGE_FILE: ${{ github.workspace }}/.coverage.probe
413438
run: |
414-
cat > "$GITHUB_WORKSPACE/probe_import.py" <<'EOF'
415-
import importlib
416-
import sys
417-
418-
name = sys.argv[1]
419-
importlib.import_module(name)
420-
print(f"PROBE OK {name}", flush=True)
421-
EOF
422-
439+
probe="$GITHUB_WORKSPACE/ci/tools/crash_probe.py"
440+
python="$GITHUB_WORKSPACE/.venv/Scripts/python"
423441
cd "${{ steps.install-root.outputs.INSTALL_ROOT }}"
424-
for mod in \
425-
cuda.core._resource_handles \
426-
cuda.core._device_resources \
427-
cuda.core._event \
428-
cuda.core._stream \
429-
cuda.core._context \
430-
cuda.core
431-
do
432-
rc=0
433-
"$GITHUB_WORKSPACE/.venv/Scripts/python" -m coverage run \
434-
--rcfile="$GITHUB_WORKSPACE/.coveragerc" \
435-
"$GITHUB_WORKSPACE/probe_import.py" "$mod" || rc=$?
436-
echo "PROBE EXIT $mod rc=$rc"
437-
done
442+
443+
rc=0
444+
"$python" "$probe" --selftest \
445+
--native-log "$GITHUB_WORKSPACE/native-selftest.txt" \
446+
--dump "$GITHUB_WORKSPACE/selftest.dmp" || rc=$?
447+
echo "SELFTEST rc=$rc"
448+
head -n 10 "$GITHUB_WORKSPACE/native-selftest.txt" || true
449+
450+
rc=0
451+
"$python" -m coverage run --rcfile="$GITHUB_WORKSPACE/.coveragerc" \
452+
"$probe" --stack-mb 8 \
453+
--native-log "$GITHUB_WORKSPACE/native-coverage.txt" \
454+
--dump "$GITHUB_WORKSPACE/crash-coverage.dmp" || rc=$?
455+
echo "COVERAGE PROBE rc=$rc"
456+
cat "$GITHUB_WORKSPACE/native-coverage.txt" || true
457+
458+
rc=0
459+
"$python" "$probe" --stack-mb 8 \
460+
--breadcrumb "$GITHUB_WORKSPACE/breadcrumb.txt" \
461+
--native-log "$GITHUB_WORKSPACE/native-breadcrumb.txt" \
462+
--dump "$GITHUB_WORKSPACE/crash-breadcrumb.dmp" || rc=$?
463+
echo "BREADCRUMB PROBE rc=$rc"
464+
echo "=== breadcrumb: $(wc -l < "$GITHUB_WORKSPACE/breadcrumb.txt") lines, last 25 ==="
465+
tail -n 25 "$GITHUB_WORKSPACE/breadcrumb.txt" || true
466+
467+
rc=0
468+
"$python" -m coverage run --rcfile="$GITHUB_WORKSPACE/.coveragerc" \
469+
"$GITHUB_WORKSPACE/ci/tools/env_fingerprint.py" --stack-mb 8 \
470+
"$GITHUB_WORKSPACE/fingerprint-windows.txt" || rc=$?
471+
echo "FINGERPRINT rc=$rc"
438472
439473
# Only this step crashes. cuda.bindings runs the same wrapper, the same
440474
# 8 MB thread and the same coverage wheel, and finishes normally, so the
@@ -532,6 +566,14 @@ jobs:
532566
crashlog-core.txt
533567
faulthandler-core.txt
534568
verbose-core.txt
569+
native-selftest.txt
570+
native-coverage.txt
571+
native-breadcrumb.txt
572+
breadcrumb.txt
573+
fingerprint-windows.txt
574+
selftest.dmp
575+
crash-coverage.dmp
576+
crash-breadcrumb.dmp
535577
retention-days: 7
536578
if-no-files-found: warn
537579

ci/tools/crash_probe.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""Import cuda.core under full instrumentation and record where it dies.
6+
7+
The crash reduces to a single import, on a thread the size of the one
8+
run_pytest_with_stack.py creates. Two records are taken around it, and they
9+
answer different halves of the same question:
10+
11+
the breadcrumb the last .pyx line that executed, i.e. which source
12+
statement was running -- written by our own trace
13+
function, which also arms Cython's linetrace hooks the
14+
way coverage does, so the fault still triggers
15+
the native log which module faulted and at what offset, from
16+
native_crash_handler
17+
18+
A run under coverage answers "does it still crash", and a run with the
19+
breadcrumb instead of coverage answers "is coverage necessary, or does any
20+
tracer do". Only one trace function can be installed at a time, so those are
21+
separate invocations rather than one.
22+
23+
crash_probe.py --stack-mb 8 --native-log native.txt --dump crash.dmp
24+
crash_probe.py --stack-mb 8 --breadcrumb lines.txt --native-log native.txt
25+
crash_probe.py --selftest --native-log native.txt
26+
27+
--selftest writes to address zero on purpose. It is how the whole chain gets
28+
verified somewhere that does not crash on its own: if the handler, the module
29+
map, the dump and the artifact upload all work there, then the one gated run
30+
on the runner that does crash is not spent discovering a typo.
31+
"""
32+
33+
from __future__ import annotations
34+
35+
import argparse
36+
import ctypes
37+
import importlib
38+
import os
39+
import sys
40+
import threading
41+
import time
42+
43+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44+
45+
import native_crash_handler
46+
47+
48+
def install_breadcrumb(path: str, needle: str) -> None:
49+
"""Log every executed line whose file matches `needle`, line buffered.
50+
51+
Cython emits __Pyx_TraceLine for module-level statements too, which is the
52+
only reason this can see anything at all: the fault happens while a module
53+
body is still executing, before any function in it has been called.
54+
"""
55+
log = open( # noqa: SIM115 - must outlive this function
56+
os.path.abspath(path), "w", buffering=1, encoding="utf-8", errors="backslashreplace"
57+
)
58+
59+
def tracer(frame, event, _arg):
60+
if event == "line":
61+
filename = frame.f_code.co_filename
62+
if needle in filename:
63+
log.write(f"{filename}:{frame.f_lineno}\n")
64+
return tracer
65+
66+
# Only one trace function exists per thread, so arming this one under
67+
# `coverage run` would quietly switch coverage off and leave a run that
68+
# looks like it measured something. Say so rather than let it pass.
69+
displaced = sys.gettrace()
70+
if displaced is not None:
71+
message = f"# WARNING: replaced an existing trace function: {displaced!r}"
72+
log.write(message + "\n")
73+
print(message, flush=True)
74+
75+
log.write(f"# breadcrumb armed, filter={needle!r}\n")
76+
sys.settrace(tracer)
77+
78+
79+
def selftest() -> None:
80+
"""Deliberately fault, to prove the handler reports what it should."""
81+
print("selftest: writing to address 0", flush=True)
82+
ctypes.memset(0, 0, 1)
83+
print("selftest: still alive -- the write did NOT fault", flush=True)
84+
85+
86+
def body(args) -> None:
87+
if args.breadcrumb:
88+
install_breadcrumb(args.breadcrumb, args.breadcrumb_filter)
89+
if args.selftest:
90+
selftest()
91+
return
92+
started = time.perf_counter()
93+
importlib.import_module(args.module)
94+
print(f"PROBE OK {args.module} in {time.perf_counter() - started:.2f}s", flush=True)
95+
96+
97+
def main() -> int:
98+
parser = argparse.ArgumentParser(description=__doc__)
99+
parser.add_argument("--module", default="cuda.core")
100+
parser.add_argument("--stack-mb", type=float, default=0.0)
101+
parser.add_argument("--native-log", default="crash-native.txt")
102+
parser.add_argument("--dump")
103+
parser.add_argument("--breadcrumb")
104+
parser.add_argument("--breadcrumb-filter", default="cuda")
105+
parser.add_argument("--selftest", action="store_true")
106+
args = parser.parse_args()
107+
108+
# Process-wide, so arming it here covers the worker thread as well.
109+
native_crash_handler.install(args.native_log, dump_path=args.dump)
110+
111+
if not args.stack_mb:
112+
body(args)
113+
return 0
114+
115+
# sys.settrace only affects the thread that calls it, so the breadcrumb is
116+
# installed inside body(), on the thread that does the import.
117+
threading.stack_size(int(args.stack_mb * 1024 * 1024))
118+
worker = threading.Thread(target=body, args=(args,), name=f"stack-{args.stack_mb:g}mb")
119+
worker.start()
120+
worker.join()
121+
return 0
122+
123+
124+
if __name__ == "__main__":
125+
sys.exit(main())

0 commit comments

Comments
 (0)