Skip to content

Commit e1610f4

Browse files
authored
fix(cli): don't change the global SIGPIPE disposition (#834)
* fix(cli): don't change the global SIGPIPE disposition Setting SIGPIPE to SIG_DFL in Application.run() made any later socket send() to a closed peer kill the whole process; rpyc servers died this way when a client disconnected. Catch BrokenPipeError around the run body instead, as the Python signal docs recommend, keeping the piped- help fix from #727. Assisted-by: ClaudeCode:claude-fable-5 * refactor(cli): flatten the nested try in Application.run Move the parse/dispatch body into _parse_and_dispatch() so run() has a single try for BrokenPipeError. Also drops a type-ignore: the subapp result now assigns to a plain Application. Assisted-by: ClaudeCode:claude-fable-5 * fix(cli): flush stdout before exit so a late EPIPE is caught Help output can sit in the stdout buffer after head exits without any write failing; the EPIPE then only surfaced at interpreter shutdown, printing 'Exception ignored ... BrokenPipeError' to stderr (seen on Linux CI). Flush inside the guarded region so the handler catches it, and add a deterministic regression test for the race. Assisted-by: ClaudeCode:claude-fable-5
1 parent c356380 commit e1610f4

2 files changed

Lines changed: 113 additions & 43 deletions

File tree

plumbum/cli/application.py

Lines changed: 55 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,46 @@ def _positional_validate(
918918

919919
return out_args
920920

921+
def _parse_and_dispatch(self, argv: list[str]) -> tuple[Application, int]:
922+
"""Parses ``argv``, runs switches and ``main()``; returns the final
923+
instance (the nested subcommand's, if one ran) and the return code."""
924+
inst: Application = self
925+
retcode: int | None = 0
926+
try:
927+
swfuncs, tailargs = self._parse_args(argv)
928+
ordered, tailargs = self._validate_args(swfuncs, tailargs)
929+
except ShowHelp:
930+
self.help()
931+
except ShowHelpAll:
932+
self.helpall()
933+
except ShowVersion:
934+
self.version()
935+
except ShowCompletion:
936+
info = swfuncs[self.completions.__func__] # type: ignore[attr-defined]
937+
self._print_completion(info.val[0])
938+
except SwitchError as ex:
939+
print(T_("Error: {0}").format(ex))
940+
print(T_("------"))
941+
self.help()
942+
retcode = 2
943+
else:
944+
for f, a in ordered:
945+
f(self, *a)
946+
947+
cleanup = None
948+
if not self.nested_command or self.CALL_MAIN_IF_NESTED_COMMAND:
949+
retcode = self.main(*tailargs)
950+
cleanup = functools.partial(self.cleanup, retcode)
951+
if not retcode and self.nested_command:
952+
subapp, argv = self.nested_command
953+
subapp.parent = self
954+
inst, retcode = subapp.run(argv, exit=False)
955+
956+
if cleanup:
957+
cleanup()
958+
959+
return inst, retcode or 0
960+
921961
@typing.overload
922962
@classmethod
923963
def run(
@@ -955,55 +995,27 @@ def run(
955995
Setting ``exit`` to ``False`` is intended for testing/debugging purposes only -- do
956996
not override it in other situations.
957997
"""
958-
# Handle SIGPIPE to avoid BrokenPipeError when output is piped (e.g., to head)
959-
# This is only available on Unix systems
960-
with contextlib.suppress(ImportError, AttributeError):
961-
import signal
962-
963-
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
964-
965998
if argv is None:
966999
argv = sys.argv
9671000
cls.autocomplete(argv)
9681001
argv = list(argv)
9691002
inst = cls(argv.pop(0))
970-
retcode = 0
9711003
try:
972-
swfuncs, tailargs = inst._parse_args(argv)
973-
ordered, tailargs = inst._validate_args(swfuncs, tailargs)
974-
except ShowHelp:
975-
inst.help()
976-
except ShowHelpAll:
977-
inst.helpall()
978-
except ShowVersion:
979-
inst.version()
980-
except ShowCompletion:
981-
info = swfuncs[inst.completions.__func__] # type: ignore[attr-defined]
982-
inst._print_completion(info.val[0])
983-
except SwitchError as ex:
984-
print(T_("Error: {0}").format(ex))
985-
print(T_("------"))
986-
inst.help()
987-
retcode = 2
988-
else:
989-
for f, a in ordered:
990-
f(inst, *a)
991-
992-
cleanup = None
993-
if not inst.nested_command or inst.CALL_MAIN_IF_NESTED_COMMAND:
994-
retcode = inst.main(*tailargs)
995-
cleanup = functools.partial(inst.cleanup, retcode)
996-
if not retcode and inst.nested_command:
997-
subapp, argv = inst.nested_command
998-
subapp.parent = inst
999-
inst_app, retcode = subapp.run(argv, exit=False)
1000-
inst = inst_app # type: ignore[assignment]
1001-
1002-
if cleanup:
1003-
cleanup()
1004-
1005-
if retcode is None:
1006-
retcode = 0
1004+
inst, retcode = inst._parse_and_dispatch(argv) # type: ignore[assignment]
1005+
if exit:
1006+
# surface an EPIPE now, while we can still handle it below
1007+
sys.stdout.flush()
1008+
except BrokenPipeError:
1009+
# The reader closed the pipe (e.g. output piped to ``head``).
1010+
# Never change the SIGPIPE disposition instead: that would make a
1011+
# socket send() to a closed peer kill the whole process.
1012+
retcode = 1
1013+
if exit:
1014+
# Point stdout at devnull so the interpreter's final flush
1015+
# doesn't raise on whatever is still buffered.
1016+
with contextlib.suppress(OSError, ValueError):
1017+
devnull = os.open(os.devnull, os.O_WRONLY)
1018+
os.dup2(devnull, sys.stdout.fileno())
10071019

10081020
if exit:
10091021
sys.exit(retcode)

tests/test_cli.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from __future__ import annotations
22

3+
import signal
4+
5+
import pytest
6+
37
from plumbum import cli, local
48
from plumbum.cli.switches import SwitchInfo
59
from plumbum.cli.terminal import get_terminal_size
@@ -493,6 +497,60 @@ def main(self):
493497
# No traceback should be in stderr
494498
assert "Traceback" not in result.stderr, f"Traceback in stderr: {result.stderr}"
495499

500+
@pytest.mark.skipif(not hasattr(signal, "SIGPIPE"), reason="requires SIGPIPE")
501+
def test_broken_pipe_at_shutdown_flush(self, tmp_path):
502+
"""Output still buffered when the reader is gone must not print
503+
'Exception ignored ... BrokenPipeError' at interpreter shutdown."""
504+
import subprocess
505+
import sys
506+
507+
script = tmp_path / "straggler.py"
508+
script.write_text(
509+
f"""
510+
import sys, time
511+
sys.path.insert(0, {str(local.cwd)!r})
512+
from plumbum.cli import Application
513+
514+
class App(Application):
515+
def main(self):
516+
print("1\\n2\\n3\\n4\\n5", flush=True)
517+
time.sleep(0.5) # let head exit
518+
print("straggler") # stays in the buffer until shutdown
519+
520+
if __name__ == '__main__':
521+
App.run()
522+
"""
523+
)
524+
result = subprocess.run(
525+
f"{sys.executable} {script} | head -5",
526+
shell=True,
527+
capture_output=True,
528+
text=True,
529+
check=False,
530+
)
531+
assert result.stderr == ""
532+
533+
@pytest.mark.skipif(not hasattr(signal, "SIGPIPE"), reason="requires SIGPIPE")
534+
def test_run_keeps_sigpipe_disposition(self):
535+
# SIG_DFL would make a socket send() to a closed peer kill the whole
536+
# process (e.g. an rpyc server) instead of raising BrokenPipeError.
537+
before = signal.getsignal(signal.SIGPIPE)
538+
try:
539+
_, rc = SimpleApp.run(["foo", "--bacon=2"], exit=False)
540+
finally:
541+
after = signal.getsignal(signal.SIGPIPE)
542+
signal.signal(signal.SIGPIPE, before)
543+
assert rc == 0
544+
assert after == before
545+
546+
def test_run_handles_broken_pipe(self):
547+
class BrokenApp(cli.Application):
548+
def main(self):
549+
raise BrokenPipeError
550+
551+
_, rc = BrokenApp.run(["app"], exit=False)
552+
assert rc == 1
553+
496554

497555
class ExcludesApp(cli.Application):
498556
alpha = cli.Flag("--alpha")

0 commit comments

Comments
 (0)