Skip to content

Commit e0784e5

Browse files
authored
Merge pull request #520 from Iamrodos/fix/519-windows-subprocess-deadlock
Fix subprocess deadlock hanging backups on Windows (#519)
2 parents b211ce6 + d95cf3a commit e0784e5

2 files changed

Lines changed: 141 additions & 23 deletions

File tree

github_backup/github_backup.py

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
1212
import platform
1313
import random
1414
import re
15-
import select
1615
import socket
1716
import ssl
1817
import subprocess
1918
import sys
19+
import threading
2020
import time
2121
from collections.abc import Generator
2222
from datetime import datetime
@@ -91,32 +91,37 @@ def logging_subprocess(
9191
child = subprocess.Popen(
9292
popenargs, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs
9393
)
94-
if sys.platform == "win32":
95-
logger.info(
96-
"Windows operating system detected - no subprocess logging will be returned"
97-
)
98-
99-
log_level = {child.stdout: stdout_log_level, child.stderr: stderr_log_level}
100-
101-
def check_io():
102-
if sys.platform == "win32":
103-
return
104-
ready_to_read = select.select([child.stdout, child.stderr], [], [], 1000)[0]
105-
for io in ready_to_read:
106-
line = io.readline()
107-
if not logger:
108-
continue
109-
if not (io == child.stderr and not line):
110-
logger.log(log_level[io], line[:-1])
111-
112-
# keep checking stdout/stderr until the child exits
113-
while child.poll() is None:
114-
check_io()
11594

116-
check_io() # check again to catch anything after the process exits
95+
def log_output(pipe, log_level):
96+
# Drain the pipe from a thread so the child never blocks on a full
97+
# pipe buffer (issue #519), logging each line as it arrives.
98+
with pipe:
99+
for line in iter(pipe.readline, b""):
100+
try:
101+
logger.log(log_level, line.rstrip(b"\r\n"))
102+
except Exception:
103+
# Keep draining even if logging fails, or the child
104+
# blocks on a full pipe buffer again
105+
pass
106+
107+
threads = [
108+
threading.Thread(
109+
target=log_output, args=(child.stdout, stdout_log_level), daemon=True
110+
),
111+
threading.Thread(
112+
target=log_output, args=(child.stderr, stderr_log_level), daemon=True
113+
),
114+
]
115+
for thread in threads:
116+
thread.start()
117117

118118
rc = child.wait()
119119

120+
# Timeout in case a grandchild inherited the pipe handles and keeps
121+
# them open past the child's exit, which would delay EOF indefinitely
122+
for thread in threads:
123+
thread.join(timeout=60)
124+
120125
if rc != 0:
121126
print("{} returned {}:".format(popenargs[0], rc), file=sys.stderr)
122127
print("\t", " ".join(popenargs), file=sys.stderr)

tests/test_logging_subprocess.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Tests for logging_subprocess pipe handling (issue #519)."""
2+
3+
import logging
4+
import sys
5+
import threading
6+
7+
import pytest
8+
9+
from github_backup import github_backup
10+
11+
12+
class TestLoggingSubprocess:
13+
"""Test suite for logging_subprocess deadlock and logging behavior."""
14+
15+
def test_large_stderr_output_does_not_deadlock(self):
16+
"""Child output larger than the OS pipe buffer must not hang.
17+
18+
Regression test for issue #519: on Windows the pipes were never
19+
drained, so the child blocked once its output exceeded the pipe
20+
buffer (~8KB) and the parent spun forever on poll().
21+
"""
22+
# Write 256KB to stderr, far past any platform's pipe buffer
23+
child_code = (
24+
"import sys\n"
25+
"for _ in range(3200):\n"
26+
" sys.stderr.write('x' * 79 + '\\n')\n"
27+
)
28+
result = {}
29+
30+
def run():
31+
result["rc"] = github_backup.logging_subprocess(
32+
[sys.executable, "-c", child_code]
33+
)
34+
35+
thread = threading.Thread(target=run, daemon=True)
36+
thread.start()
37+
thread.join(timeout=30)
38+
39+
assert not thread.is_alive(), "logging_subprocess deadlocked on large output"
40+
assert result["rc"] == 0
41+
42+
def test_stdout_logged_at_debug_stderr_at_error(self, caplog):
43+
"""stdout lines log at DEBUG and stderr lines at ERROR."""
44+
child_code = (
45+
"import sys\n"
46+
"print('to stdout')\n"
47+
"print('to stderr', file=sys.stderr)\n"
48+
)
49+
50+
with caplog.at_level(logging.DEBUG, logger="github_backup.github_backup"):
51+
rc = github_backup.logging_subprocess([sys.executable, "-c", child_code])
52+
53+
assert rc == 0
54+
records = [
55+
(r.levelno, r.getMessage())
56+
for r in caplog.records
57+
if r.name == "github_backup.github_backup"
58+
]
59+
assert (logging.DEBUG, str(b"to stdout")) in records
60+
assert (logging.ERROR, str(b"to stderr")) in records
61+
62+
def test_trailing_newlines_stripped(self, caplog):
63+
"""Logged lines have trailing \\r\\n stripped, including Windows CRLF."""
64+
child_code = (
65+
"import sys\n"
66+
"sys.stdout.buffer.write(b'crlf line\\r\\n')\n"
67+
"sys.stdout.buffer.write(b'lf line\\n')\n"
68+
)
69+
70+
with caplog.at_level(logging.DEBUG, logger="github_backup.github_backup"):
71+
rc = github_backup.logging_subprocess([sys.executable, "-c", child_code])
72+
73+
assert rc == 0
74+
messages = [
75+
r.getMessage()
76+
for r in caplog.records
77+
if r.name == "github_backup.github_backup"
78+
]
79+
assert str(b"crlf line") in messages
80+
assert str(b"lf line") in messages
81+
82+
def test_final_line_without_newline_not_truncated(self, caplog):
83+
"""A final line with no trailing newline keeps its last character.
84+
85+
The old code stripped the newline with line[:-1], which chopped the
86+
last character off any line that did not end with a newline.
87+
"""
88+
child_code = "import sys\nsys.stdout.write('no newline')\n"
89+
90+
with caplog.at_level(logging.DEBUG, logger="github_backup.github_backup"):
91+
rc = github_backup.logging_subprocess([sys.executable, "-c", child_code])
92+
93+
assert rc == 0
94+
messages = [
95+
r.getMessage()
96+
for r in caplog.records
97+
if r.name == "github_backup.github_backup"
98+
]
99+
assert str(b"no newline") in messages
100+
101+
def test_returns_child_exit_code(self, capsys):
102+
"""Non-zero child exit codes are returned and summarized on stderr."""
103+
rc = github_backup.logging_subprocess(
104+
[sys.executable, "-c", "import sys; sys.exit(3)"]
105+
)
106+
107+
assert rc == 3
108+
captured = capsys.readouterr()
109+
assert "returned 3" in captured.err
110+
111+
112+
if __name__ == "__main__":
113+
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)