Skip to content

Commit 9f0f7a6

Browse files
Merge pull request #65 from liammcinroy/main
Add `--sigterm` flag
2 parents e39a802 + 5f4d475 commit 9f0f7a6

4 files changed

Lines changed: 117 additions & 2 deletions

File tree

src/slipcover/__main__.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import platform
99
import functools
1010
import os
11+
import signal
1112
import tempfile
1213
import json
1314
import warnings
@@ -224,6 +225,7 @@ def build_parser():
224225
ap.add_argument('--threshold', type=int, default=50, metavar="T",
225226
help="threshold for de-instrumentation (if not immediate)")
226227
ap.add_argument('--missing-width', type=int, default=80, metavar="WIDTH", help="maximum width for `missing' column")
228+
ap.add_argument('--sigterm', action='store_true', help="if true, register a SIGTERM signal handler to capture data when the process ends due to a SIGTERM signal.")
227229

228230
# intended for slipcover development only
229231
ap.add_argument('--silent', action='store_true', help=argparse.SUPPRESS)
@@ -351,8 +353,25 @@ def printit(coverage, outfile):
351353

352354
atexit.register(sci_atexit)
353355

354-
return_code = 0
356+
# Windows doesn't have a SIGTERM signal.
357+
if args.sigterm and platform.system() != 'Windows':
358+
def sci_sigterm_handler(signum, frame):
359+
# A forked child's correct exit path is os._exit(), already
360+
# shimmed (exit_shim) to write its own coverage to a tempfile
361+
# for the parent to merge -- going through sys.exit()/atexit
362+
# here instead would run the top-level sci_atexit() in the
363+
# child too, racing the real parent for the same --out file.
364+
if output_tmpfile:
365+
os._exit(0)
366+
else:
367+
# atexit.register(sci_atexit) above already runs sci_atexit()
368+
# on any normal interpreter shutdown, including one
369+
# triggered by this SystemExit -- no need to call it here.
370+
sys.exit(0)
355371

372+
signal.signal(signal.SIGTERM, sci_sigterm_handler)
373+
374+
return_code = 0
356375
if args.script:
357376
# python 'globals' for the script being executed
358377
script_globals: Dict[Any, Any] = dict()

src/slipcover/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def derive_configurable_keys(ap):
115115
"pretty-print",
116116
"immediate",
117117
"skip-covered",
118+
"sigterm",
118119
}
119120

120121

tests/test_config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,18 @@ def _make_args(**kwargs):
169169
out=None, source=None, omit=None,
170170
immediate=False, skip_covered=False, fail_under=0,
171171
threshold=50, missing_width=80, silent=False, dis=False,
172-
debug=False, dont_wrap_pytest=False,
172+
debug=False, dont_wrap_pytest=False, sigterm=False,
173173
)
174174
defaults.update(kwargs)
175175
return argparse.Namespace(**defaults)
176176

177177

178+
def test_apply_config_sigterm():
179+
args = _make_args()
180+
apply_config({"sigterm": True}, args)
181+
assert args.sigterm is True
182+
183+
178184
def test_apply_config_lcov_keys():
179185
args = _make_args()
180186
apply_config({"format": "lcov", "lcov-test-name": "Suite", "lcov-comments": ["a", "b"]}, args)

tests/test_coverage.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1804,3 +1804,92 @@ def test_lcov_flag_with_merge(cov_merge_fixture):
18041804
assert 'DA:6,1' in da_lines
18051805
assert 'DA:9,0' in da_lines
18061806
assert 'end_of_record' in lines
1807+
1808+
1809+
@pytest.mark.skipif(sys.platform == 'win32', reason='SIGTERM is Unix-specific')
1810+
def test_sigterm_top_level_writes_single_correct_report(tmp_path, monkeypatch):
1811+
"""A SIGTERM'd top-level process must produce exactly one coverage
1812+
report. The original bug called sci_atexit() manually and then let
1813+
atexit run it again via sys.exit(), printing the table twice.
1814+
"""
1815+
import signal
1816+
import time
1817+
1818+
monkeypatch.chdir(tmp_path)
1819+
script = tmp_path / "script.py"
1820+
script.write_text(dedent("""\
1821+
import time
1822+
x = 1
1823+
time.sleep(10)
1824+
y = 2 # must never execute -- process is killed during sleep
1825+
"""))
1826+
1827+
proc = subprocess.Popen(
1828+
[sys.executable, '-m', 'slipcover', '--sigterm', 'script.py'],
1829+
cwd=tmp_path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
1830+
)
1831+
1832+
time.sleep(1) # let slipcover start up and reach the sleep
1833+
proc.send_signal(signal.SIGTERM)
1834+
stdout, stderr = proc.communicate(timeout=10)
1835+
1836+
assert proc.returncode == 0, f"stdout={stdout}\nstderr={stderr}"
1837+
# the report table's header appears exactly once per report -- the
1838+
# original bug printed it twice
1839+
assert stdout.count('#lines') == 1, f"expected exactly one report, got:\n{stdout}"
1840+
assert 'script.py' in stdout
1841+
1842+
1843+
@pytest.mark.skipif(sys.platform == 'win32', reason='SIGTERM/fork are Unix-specific')
1844+
def test_sigterm_forked_child_writes_partial_coverage_safely(tmp_path, monkeypatch):
1845+
"""A forked child killed by SIGTERM must exit through the shimmed
1846+
os._exit() (writing its own partial coverage to a tempfile for the
1847+
parent to merge) rather than racing the parent through the top-level
1848+
report path -- exercised via the real --sigterm flag and a real
1849+
os.fork() in the target script, not by calling internal functions
1850+
directly.
1851+
"""
1852+
import os
1853+
import signal
1854+
import time
1855+
1856+
monkeypatch.chdir(tmp_path)
1857+
script = tmp_path / "script.py"
1858+
script.write_text(dedent("""\
1859+
import os, time
1860+
1861+
pid = os.fork()
1862+
if pid == 0:
1863+
x = 1
1864+
time.sleep(10)
1865+
y = 2 # must never execute -- child is killed during sleep
1866+
else:
1867+
with open("child_pid.txt", "w") as f:
1868+
f.write(str(pid))
1869+
os.waitpid(pid, 0)
1870+
"""))
1871+
1872+
proc = subprocess.Popen(
1873+
[sys.executable, '-m', 'slipcover', '--sigterm', '--json', '--out', 'out.json', 'script.py'],
1874+
cwd=tmp_path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
1875+
)
1876+
1877+
pid_file = tmp_path / "child_pid.txt"
1878+
for _ in range(100): # up to ~5s
1879+
if pid_file.exists():
1880+
break
1881+
time.sleep(0.05)
1882+
else:
1883+
proc.kill()
1884+
pytest.fail("forked child never started")
1885+
1886+
child_pid = int(pid_file.read_text())
1887+
os.kill(child_pid, signal.SIGTERM)
1888+
1889+
stdout, stderr = proc.communicate(timeout=10)
1890+
assert proc.returncode == 0, f"stdout={stdout}\nstderr={stderr}"
1891+
1892+
cov = json.loads((tmp_path / "out.json").read_text())
1893+
file_cov = cov['files']['script.py']
1894+
assert 5 in file_cov['executed_lines'] # x = 1, in the child
1895+
assert 7 not in file_cov['executed_lines'] # y = 2, never reached

0 commit comments

Comments
 (0)