@@ -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 } \n stderr={ 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 } \n stderr={ 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