Skip to content

Commit ae54565

Browse files
authored
fix: preserve JSON output for slow consumers at shutdown (#7428)
* fix: preserve JSON output for slow consumers at shutdown * fix: bound output draining and synchronize shutdown snapshots * ci: rerun PR checks
1 parent 4871a3a commit ae54565

3 files changed

Lines changed: 139 additions & 20 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Avoid premature truncation of JSON logs when consumers read slowly during shutdown, while limiting output draining to 30 seconds after the command exits.

‎packages/reflex-base/src/reflex_base/utils/log.py‎

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -272,9 +272,12 @@ def _write_json(payload: dict, *, stderr: bool):
272272
# stops waiting for descendants that still hold the pipe.
273273
_DRAIN_IDLE_SECONDS = 0.5
274274

275-
# How long the supervisor drains the pipes after the child exits, at most.
275+
# Maximum drain time after the child exits, excluding time forwarding output.
276276
_DRAIN_MAX_SECONDS = 5
277277

278+
# Stop shutdown after 30 seconds even when a consumer blocks every write.
279+
_DRAIN_WALL_SECONDS = 30
280+
278281
# Line ends in child output; a lone ``\r`` ends a progress-bar update.
279282
_LINE_END = re.compile(rb"\r\n|\r|\n")
280283

@@ -390,6 +393,9 @@ def __init__(self, read_fd: int, write_fd: int, level: str, name: str):
390393
self.logger_name = name
391394
# When the reader started waiting for data; None while it works.
392395
self.idle_since: float | None = None
396+
# Cumulative write time and the start of any ongoing write.
397+
self._write_state: tuple[float, float | None] = (0.0, None)
398+
self._write_lock = threading.Lock()
393399

394400
def run(self):
395401
"""Forward the pipe until every writer closed it."""
@@ -420,27 +426,53 @@ def _write(self, lines: list[bytes], traceback: list[str], final: bool):
420426
final: Whether the output ended.
421427
"""
422428
data = _to_records(lines, traceback, self.level, self.logger_name, final)
429+
with self._write_lock:
430+
elapsed, _ = self._write_state
431+
started = time.monotonic()
432+
self._write_state = (elapsed, started)
423433
# Keep reading when the consumer is gone: a full pipe blocks the child.
424434
with contextlib.suppress(OSError):
425435
_write_all(self.write_fd, data)
436+
with self._write_lock:
437+
self._write_state = (elapsed + time.monotonic() - started, None)
438+
439+
def writing_snapshot(self) -> tuple[float, float]:
440+
"""Capture the current time and cumulative write duration together.
426441
427-
def drain(self, exited_at: float):
442+
Returns:
443+
The monotonic time and seconds spent writing up to that time.
444+
"""
445+
with self._write_lock:
446+
elapsed, started = self._write_state
447+
now = time.monotonic()
448+
return now, elapsed if started is None else elapsed + now - started
449+
450+
def drain(self, exited_at: float, writing_at_exit: float, wall_deadline: float):
428451
"""Wait until the pipe is drained after the child exited.
429452
430453
Returns early when the reader sat idle for a while after the exit, or
431-
when the drain took too long: only a descendant that outlived the
432-
child still holds the pipe.
454+
when the drain took too long. Time spent forwarding output does not
455+
count toward the active limit. A separate wall deadline bounds shutdown
456+
even when a consumer stops reading.
433457
434458
Args:
435-
exited_at: The monotonic time the child exited.
459+
exited_at: The monotonic time of the pump's snapshot after child exit.
460+
writing_at_exit: The cumulative write time at that snapshot.
461+
wall_deadline: The shared monotonic deadline for shutdown.
436462
"""
437463
while self.is_alive():
438464
self.join(0.05)
439-
now = time.monotonic()
465+
now, writing_elapsed = self.writing_snapshot()
440466
idle_since = self.idle_since
441-
if now - exited_at > _DRAIN_MAX_SECONDS or (
442-
idle_since is not None
443-
and now - max(idle_since, exited_at) > _DRAIN_IDLE_SECONDS
467+
# Seconds charged against the five-second active drain budget.
468+
drain_elapsed = now - exited_at - (writing_elapsed - writing_at_exit)
469+
if (
470+
now > wall_deadline
471+
or drain_elapsed > _DRAIN_MAX_SECONDS
472+
or (
473+
idle_since is not None
474+
and now - max(idle_since, exited_at) > _DRAIN_IDLE_SECONDS
475+
)
444476
):
445477
return
446478

@@ -492,9 +524,10 @@ def supervise_output(args: list[str]) -> int:
492524
except KeyboardInterrupt:
493525
# The child gets the same interrupt and shuts down on its own.
494526
continue
495-
exited_at = time.monotonic()
496-
for pump in pumps:
497-
pump.drain(exited_at)
527+
wall_deadline = time.monotonic() + _DRAIN_WALL_SECONDS
528+
snapshots = [pump.writing_snapshot() for pump in pumps]
529+
for pump, (exited_at, writing_at_exit) in zip(pumps, snapshots, strict=True):
530+
pump.drain(exited_at, writing_at_exit, wall_deadline)
498531
# A child killed by a signal reports -signum; shells report 128 + signum.
499532
return returncode if returncode >= 0 else 128 - returncode
500533

‎tests/units/reflex_base/utils/test_log.py‎

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,32 +1002,117 @@ def test_supervise_output_turns_every_line_into_json(tmp_path):
10021002
from reflex_base.utils import log
10031003
10041004
if not log.is_output_supervised():
1005-
sys.exit(log.supervise_output([sys.executable, __file__]))
1005+
sys.exit(log.supervise_output([sys.executable, __file__, *sys.argv[1:]]))
10061006
for i in range(200):
1007-
print(f"line {i:03d} " + "x" * 1000)
1007+
print(f"line {i:03d} " + "x" * 1000, file=getattr(sys, sys.argv[1]))
10081008
"""
10091009

10101010

1011-
def test_supervise_output_drains_everything_for_a_slow_consumer(tmp_path):
1011+
@pytest.mark.parametrize("drain_max_seconds", [log._DRAIN_MAX_SECONDS, 0.05])
1012+
@pytest.mark.parametrize("stream", ["stdout", "stderr"])
1013+
def test_supervise_output_drains_everything_for_a_slow_consumer(
1014+
tmp_path, drain_max_seconds, stream
1015+
):
10121016
"""Output still in the pipes at exit reaches a consumer that reads late."""
10131017
script = tmp_path / "burst.py"
1014-
script.write_text(_BURST_SCRIPT)
1018+
script.write_text(
1019+
f"from reflex_base.utils import log\n"
1020+
f"log._DRAIN_MAX_SECONDS = {drain_max_seconds}\n" + _BURST_SCRIPT
1021+
)
10151022
with subprocess.Popen(
1016-
[sys.executable, str(script)],
1017-
stdout=subprocess.PIPE,
1023+
[sys.executable, str(script), stream],
1024+
stdout=subprocess.PIPE if stream == "stdout" else None,
1025+
stderr=subprocess.PIPE if stream == "stderr" else None,
10181026
env={**os.environ, "REFLEX_LOG_JSON": "true"},
10191027
) as proc:
1020-
assert proc.stdout is not None
1028+
output = proc.stdout if stream == "stdout" else proc.stderr
1029+
assert output is not None
10211030
lines = []
10221031
# A consumer slower than the writer keeps the pipes full at exit.
1023-
for line in proc.stdout:
1032+
for line in output:
10241033
lines.append(line)
10251034
time.sleep(0.015)
10261035
proc.wait(timeout=30)
1036+
assert proc.returncode == 0
10271037
assert len(lines) == 200
10281038
assert json.loads(lines[-1])["message"].startswith("line 199 ")
10291039

10301040

1041+
def test_output_pump_write_snapshots_use_a_consistent_cutoff(monkeypatch):
1042+
"""Snapshots measure a blocked or completed write at their own cutoff."""
1043+
pump = log._OutputPump(0, 1, "info", "stdout")
1044+
snapshots = []
1045+
1046+
def write(fd, data):
1047+
"""Capture a snapshot while the simulated write is in progress.
1048+
1049+
Args:
1050+
fd: The output file descriptor.
1051+
data: The bytes to write.
1052+
1053+
Returns:
1054+
The number of bytes written.
1055+
"""
1056+
snapshots.append(pump.writing_snapshot())
1057+
return len(data)
1058+
1059+
monkeypatch.setattr(
1060+
time, "monotonic", mock.Mock(side_effect=[10.0, 15.0, 20.0, 25.0])
1061+
)
1062+
monkeypatch.setattr(os, "write", write)
1063+
pump._write([b"line"], [], final=False)
1064+
assert snapshots == [(15.0, 5.0)]
1065+
assert pump.writing_snapshot() == (25.0, 10.0)
1066+
1067+
1068+
_STALLED_CONSUMER_SCRIPT = """
1069+
import os
1070+
import subprocess
1071+
import sys
1072+
from pathlib import Path
1073+
1074+
from reflex_base.utils import log
1075+
1076+
log._DRAIN_WALL_SECONDS = 0.5
1077+
if not log.is_output_supervised():
1078+
sys.exit(log.supervise_output([sys.executable, __file__]))
1079+
writer = subprocess.Popen([
1080+
sys.executable,
1081+
"-c",
1082+
"import os\\nwhile True:\\n os.write(int(os.environ['TEST_OUTPUT_FD']), (b'x' * 1023 + b'\\\\n') * 64)",
1083+
])
1084+
Path("writer.pid").write_text(str(writer.pid))
1085+
"""
1086+
1087+
1088+
@pytest.mark.parametrize("stream_fd", [1, 2])
1089+
def test_supervise_output_bounds_shutdown_for_a_stalled_consumer(tmp_path, stream_fd):
1090+
"""A lingering writer and an unread output pipe cannot prevent shutdown."""
1091+
script = tmp_path / "stalled.py"
1092+
script.write_text(_STALLED_CONSUMER_SCRIPT)
1093+
with subprocess.Popen(
1094+
[sys.executable, str(script)],
1095+
stdout=subprocess.PIPE,
1096+
stderr=subprocess.PIPE,
1097+
cwd=tmp_path,
1098+
env={
1099+
**os.environ,
1100+
"REFLEX_LOG_JSON": "true",
1101+
"TEST_OUTPUT_FD": str(stream_fd),
1102+
},
1103+
) as proc:
1104+
try:
1105+
assert proc.wait(timeout=5) == 0
1106+
finally:
1107+
if proc.poll() is None:
1108+
proc.kill()
1109+
proc.wait(timeout=5)
1110+
pid_file = tmp_path / "writer.pid"
1111+
if pid_file.exists():
1112+
with contextlib.suppress(OSError):
1113+
os.kill(int(pid_file.read_text()), signal.SIGTERM)
1114+
1115+
10311116
_LINGERING_SCRIPT = """
10321117
import subprocess
10331118
import sys

0 commit comments

Comments
 (0)