Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b4ee0bd
coverage: measure subprocesses spawned by tests
asmacdo Sep 30, 2026
a1ea315
coverage: save data before exec so the duct shim is measured
asmacdo Sep 30, 2026
9adec16
gitignore: per-process coverage data files
asmacdo Sep 30, 2026
d6e91ee
test: add start_duct helper that waits until duct handles signals
asmacdo Sep 30, 2026
da47787
signals: forward Ctrl-C before logging it
asmacdo Sep 30, 2026
bc8b246
test: run test_signal_int as e2e, signalling only once duct is ready
asmacdo Sep 30, 2026
98588d1
signals: install the SIGINT handler before starting the command
asmacdo Sep 30, 2026
a229c99
test: run test_signal_kill as e2e, waiting for each step
asmacdo Sep 30, 2026
d990831
test: keep the session-mode marker alive for the whole test
asmacdo Sep 30, 2026
f1fd650
test: let signal_ignorer.py live 60 s, like the other test caps
asmacdo Oct 1, 2026
56b7595
test: start_duct sets --log-level=INFO itself
asmacdo Oct 1, 2026
dd4fc7b
test: show duct's stderr when a signal test fails early
asmacdo Oct 1, 2026
9ca6061
tox: require coverage >= 7.10 for the test environments
asmacdo Oct 1, 2026
deb62e3
signals: do not crash when a Ctrl-C is handled after the command exited
asmacdo Oct 1, 2026
4cb7077
signals: start duct's threads with SIGINT blocked
asmacdo Oct 1, 2026
2d3e841
test: cover --fail-time thresholds on their own
asmacdo Oct 1, 2026
b96eb27
test: signal tests use only run-time-independent --fail-time values
asmacdo Oct 1, 2026
f40bba8
signals: write the log message raw when the stderr writer is locked
asmacdo Oct 8, 2026
4bb6aa9
fixup! test: keep the session-mode marker alive for the whole test
asmacdo Oct 8, 2026
5f3b75c
signals: block SIGINT while attach() hands the pid over
leej3 Oct 12, 2026
865f4b9
signals: share one context manager for blocking SIGINT
asmacdo Oct 12, 2026
33579a5
test: test_session_modes waits until duct has sampled its command
asmacdo Oct 1, 2026
3f937d5
test: test_execution_summary waits until duct has sampled its command
asmacdo Oct 1, 2026
e2cc66d
test: test_spawn_children waits until duct has sampled every child
asmacdo Oct 1, 2026
6d4fd82
test: setsid children of test_spawn_children are born outside duct's …
asmacdo Oct 1, 2026
b3ccb52
test: remove the commented-out flaky markers
asmacdo Oct 1, 2026
c673612
test: drop pytest-rerunfailures and the flaky marker
asmacdo Oct 8, 2026
4dd5104
ci: install setsid on macOS so the setsid cases run there
leej3 Oct 12, 2026
86612a2
contributing: note that the setsid tests need setsid, absent on macOS
asmacdo Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ jobs:
name: Install libjpeg on Ubuntu
run: sudo apt-get install -y libjpeg-dev

- name: Install setsid on macOS
if: runner.os == 'macOS'
run: |
brew install util-linux
echo "$(brew --prefix util-linux)/bin" >> "$GITHUB_PATH"

- name: Install uv
if: matrix.toxenv == 'oldestdeps'
run: python -m pip install uv
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ __pycache__/
src/__pycache__/
*.egg-info/
.coverage
.coverage.*
coverage.*
.duct/
.idea
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,9 @@ It is also possible to use pytest directly, but you will need to override the op
`tox.ini` with `addopts`. Invocation in this way can be faster while developing::

python -m pytest -o addopts="" test/test_thething.py::specific_test

Some tests need the ``setsid`` command and are skipped where it is missing.
macOS does not ship it; to run those tests there, install util-linux and put it on ``PATH``::

brew install util-linux
export PATH="$(brew --prefix util-linux)/bin:$PATH"
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,6 @@ filterwarnings = [
# matplotlib 3.5 uses deprecated pyparsing API (enablePackrat -> enable_packrat)
"ignore:'enablePackrat' deprecated:DeprecationWarning:matplotlib._mathtext",
]
markers = [
"flaky: mark a test as being unreliable",
]
norecursedirs = ["test/data"]

[tool.setuptools.packages.find]
Expand Down Expand Up @@ -117,6 +114,7 @@ src_paths = ["src"]
[tool.coverage.run]
branch = true
parallel = true
patch = ["subprocess", "execv"]

[tool.coverage.paths]
source = [
Expand Down
21 changes: 16 additions & 5 deletions src/con_duct/_duct_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing import IO, TextIO
from con_duct._models import LogPaths, Outputs, RecordTypes, SessionMode
from con_duct._output import TailPipe, prepare_outputs, remove_files, safe_close_files
from con_duct._signals import SigIntHandler
from con_duct._signals import SigIntHandler, start_thread
from con_duct._tracker import Report, monitor_process

__version__ = version("con-duct")
Expand Down Expand Up @@ -65,6 +65,11 @@ def execute(
sample_interval,
)

# Install before any command exists so no SIGINT can kill duct and orphan
# the command; until attach() the handler only counts them.
sigint_handler = SigIntHandler()
signal.signal(signal.SIGINT, sigint_handler)

log_paths = LogPaths.create(output_prefix, pid=os.getpid())
try:
log_paths.prepare_paths(clobber, capture_outputs)
Expand Down Expand Up @@ -99,6 +104,12 @@ def execute(
)
files_to_close.append(report.usage_file)

if sigint_handler.sigcount:
safe_close_files(files_to_close)
remove_files(log_paths, assert_empty=True)
lgr.info("Received SIGINT before starting %r, not starting it", command)
return 128 + signal.SIGINT

report.start_time = time.time()
try:
report.process = process = subprocess.Popen(
Expand All @@ -118,7 +129,7 @@ def execute(
lgr.error("%s: command not found", command)
return 127 # seems what zsh and bash return then

signal.signal(signal.SIGINT, SigIntHandler(process.pid))
sigint_handler.attach(process.pid)
lgr.info("duct %s is executing %r...", __version__, full_command)
lgr.info("Log files will be written to %s", log_paths.prefix)
try:
Expand All @@ -145,15 +156,15 @@ def execute(
monitoring_thread = threading.Thread(
target=monitor_process, args=monitoring_args
)
monitoring_thread.start()
start_thread(monitoring_thread)
else:
monitoring_thread = None

if record_types.has_system_summary():
env_thread = threading.Thread(target=report.collect_environment)
env_thread.start()
start_thread(env_thread)
sys_info_thread = threading.Thread(target=report.get_system_info)
sys_info_thread.start()
start_thread(sys_info_thread)
else:
env_thread, sys_info_thread = None, None

Expand Down
3 changes: 2 additions & 1 deletion src/con_duct/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import time
from typing import IO, Any, TextIO
from con_duct._models import LogPaths, Outputs
from con_duct._signals import start_thread


class TailPipe:
Expand All @@ -27,7 +28,7 @@ def start(self) -> None:
self.stop_event = threading.Event()
self.infile = open(self.file_path, "rb")
self.thread = threading.Thread(target=self._tail, daemon=True)
self.thread.start()
start_thread(self.thread)

def fileno(self) -> int:
assert self.infile is not None
Expand Down
115 changes: 106 additions & 9 deletions src/con_duct/_signals.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,138 @@
"""Signal handlers for con-duct."""

from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
import logging
import os
import signal
import threading
from types import FrameType
from typing import Optional

lgr = logging.getLogger("con-duct")


@contextmanager
def _sigint_blocked() -> Iterator[None]:
"""Block SIGINT in the calling thread, restoring its mask on exit.

A SIGINT that arrives meanwhile stays pending and is handled once the
mask is restored.
"""
old_mask = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGINT})
try:
yield
finally:
signal.pthread_sigmask(signal.SIG_SETMASK, old_mask)


def start_thread(thread: threading.Thread) -> None:
"""Start a thread that the kernel will never hand a SIGINT to.

A signal sent to the process is delivered to any one thread that does
not block it, but Python runs the handler only in the main thread. If
another thread takes a SIGINT, the main thread stays asleep in its wait
for the command and the Ctrl-C is not forwarded until the command exits.
A new thread inherits its creator's signal mask, so block SIGINT just
while starting it. Every thread duct starts must be started this way.

Parameters
----------
thread : threading.Thread
The thread to start
"""
with _sigint_blocked():
thread.start()


class SigIntHandler:
"""
Handler of SIGINT signals received by the process running duct.
"""

def __init__(self, pid: int) -> None:
"""
def __init__(self) -> None:
# Installed before the command starts; SIGINTs are only counted
# until attach() says which process to forward them to.
self.pid: Optional[int] = None
self.sigcount: int = 0

def attach(self, pid: int) -> None:
"""Start forwarding SIGINTs to the command.

Parameters
----------
pid : int
The PID of the process monitored by duct
The PID of the process monitored by duct. A SIGINT that arrived
while it was being started is acted on now.
"""
self.pid: int = pid
self.sigcount: int = 0
# A SIGINT after publishing the PID must not be forwarded both by
# __call__ and by the pending-signal check below.
with _sigint_blocked():
self.pid = pid
if self.sigcount:
self._act()

def __call__(self, _sig: int, _frame: Optional[FrameType]) -> None:
self.sigcount += 1
if self.pid is not None:
self._act()

def _act(self) -> None:
try:
self._forward()
except ProcessLookupError:
# The command has already exited. Python runs this handler in
# the main thread at its next bytecode, which can be only after
# the wait for the command returns.
_log(
logging.WARNING,
"Received SIGINT, but the command has already exited",
)

def _forward(self) -> None:
assert self.pid is not None
# Act before logging: the handler can interrupt duct mid-write to
# stderr, and a failed log write must not stop the signal.
if self.sigcount == 1:
lgr.info("Received SIGINT, passing to command")
os.kill(self.pid, signal.SIGINT)
_log(logging.INFO, "Received SIGINT, passing to command")
elif self.sigcount == 2:
lgr.info("Received second SIGINT, again passing to command")
os.kill(self.pid, signal.SIGINT)
_log(logging.INFO, "Received second SIGINT, again passing to command")
elif self.sigcount == 3:
lgr.warning("Received third SIGINT, forcefully killing command process")
os.kill(self.pid, signal.SIGKILL)
_log(
logging.WARNING,
"Received third SIGINT, forcefully killing command process",
)
elif self.sigcount >= 4:
lgr.critical("Exiting duct, skipping cleanup")
_log(logging.CRITICAL, "Exiting duct, skipping cleanup")
os._exit(1)


def _log(level: int, msg: str) -> None:
"""Log from a signal handler without ever raising.

If the signal arrived while duct was writing to stderr, logging raises
``RuntimeError: reentrant call`` (even its error report does): the
buffered writer is held by the write the handler interrupted, which
cannot finish until the handler returns. Write the message straight to
file descriptor 2 instead, if the log level lets the message through.
It does not go through the log format, and it may land in the middle of
the log line that was being written, so it starts on a new line and says
why it looks different.
"""
try:
lgr.log(level, msg)
except RuntimeError:
if not lgr.isEnabledFor(level):
return
try:
line = (
f"\ncon-duct [{logging.getLevelName(level)}] (written directly: "
f"the signal interrupted a log write): {msg}\n"
)
os.write(2, line.encode())
except OSError:
pass
16 changes: 15 additions & 1 deletion test/data/signal_ignorer.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
#!/usr/bin/env python3
"""Ignore SIGINT, reporting progress through the file given as argv[1].

Writes "ready" once the handler is installed, then one "SIGINT" line per
SIGINT received, so a test can wait for each step instead of sleeping.
"""

import signal
import sys
import time
from types import FrameType
from typing import Optional


def report(line: str) -> None:
with open(sys.argv[1], "a") as f:
f.write(line + "\n")


def handle_signal(sig: int, _frame: Optional[FrameType]) -> None:
print(f"Received {sig}")
report("SIGINT")


if __name__ == "__main__":
signal.signal(signal.SIGINT, handle_signal)
signal.siginterrupt(
signal.SIGINT, False
) # Restart interrupted system calls so we can test multiple SIGINTS
report("ready")
t0 = time.time()
while time.time() - t0 < 10:
while time.time() - t0 < 60:
time.sleep(0.01)
Loading
Loading