Files
app/backend/tests/flow/test_logs.py
T
stroblmeandClaude Opus 5 c09095d369 Do not fail a node because the engine's own stdout is gone
The log tee wrote through to the real stream unguarded, and the worker
pool tees a returned call's logs there after reading its result and
before handing it back — so a dead stdout, which `fluksio serve` makes
possible by running the engine as a child of the dashboard holding that
pipe, failed the node with its outputs already in hand. The capture half
runs first, so swallowing the write loses nothing.

Also: `flow_events` catches the RuntimeError a peer leaving mid-send
raises, which is a disconnect by another route, and the remote agent no
longer raises out of the task when its subprocess died before it could
be written to — the read below reports that and ends the call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXQv6KNyyvY7Z1etYTUUAd
2026-08-31 07:52:33 +02:00

149 lines
4.4 KiB
Python

"""What a node prints reaches the editor, attributed to that node."""
import sys
from typing import Any
from fluksio.flow import logs
from fluksio.flow.controller import with_settings
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import Node
from fluksio.flow.pipeline import Pipeline
def run_with_capture(nodes: list[Node], bus: "RecordingBus") -> None:
"""Run a graph the way the app does, tee first.
The app installs the tee once at startup; pytest replaces ``sys.stdout``
around each test, so it is installed here rather than in a fixture.
"""
logs.install()
Pipeline(nodes=nodes, events=bus).run({})
class RecordingBus:
"""Stands in for the event bus without an event loop behind it."""
def __init__(self) -> None:
self.events: list[dict[str, Any]] = []
def publish(self, event: dict[str, Any]) -> None:
self.events.append(event)
def make_node(node_id: str, f, provides=()) -> Node:
node = Node(f=f, provides=list(provides), name=node_id)
node.assign_flow("demo", node_id)
return node
def logs_of(bus: RecordingBus) -> list[dict[str, Any]]:
return [event for event in bus.events if event["type"] == "node_log"]
def test_what_a_node_prints_is_reported_against_it():
def talkative(params):
print("value looks fine")
return {"temp": 20.0}
bus = RecordingBus()
node = make_node("chatty", talkative, provides=[MessageSpec(name="temp")])
run_with_capture([node], bus)
captured = logs_of(bus)
assert len(captured) == 1
assert captured[0]["node"] == "demo.chatty"
assert captured[0]["level"] == "info"
assert "value looks fine" in captured[0]["text"]
def test_a_quiet_node_produces_no_log_event():
bus = RecordingBus()
node = make_node(
"quiet",
lambda params: {"temp": 20.0},
provides=[MessageSpec(name="temp", dtype=DType.FLOAT)],
)
run_with_capture([node], bus)
assert logs_of(bus) == []
def test_a_failing_node_reports_its_traceback():
# Compiled the way the controller compiles node source, because the frame
# trimming keys on that filename.
namespace: dict[str, Any] = {}
exec(
compile(
'def process():\n print("about to fail")\n'
' raise RuntimeError("boom")\n',
"<node demo.broken>",
"exec",
),
namespace,
)
bus = RecordingBus()
# Wrapped the way the controller wraps it, so the settings a node declares
# arrive as keyword arguments and the frames match the real call.
run_with_capture(
[make_node("broken", with_settings(namespace["process"], {}))], bus
)
captured = logs_of(bus)
assert len(captured) == 1
assert captured[0]["level"] == "error"
# Both what it printed and where it broke, which the node bubble has no
# room for.
assert "about to fail" in captured[0]["text"]
assert "RuntimeError: boom" in captured[0]["text"]
# The frames above the node belong to the engine that called it.
assert "pipeline.py" not in captured[0]["text"]
def test_a_flood_is_truncated_rather_than_streamed():
def noisy(params):
for index in range(1000):
print(f"line {index}")
return None
bus = RecordingBus()
run_with_capture([make_node("noisy", noisy)], bus)
captured = logs_of(bus)
# One event per execution, whatever the node prints.
assert len(captured) == 1
assert captured[0]["truncated"] is True
assert len(captured[0]["text"]) <= 8192
def test_printing_outside_a_node_still_reaches_the_real_stream(capsys):
logs.install()
print("server talking")
assert "server talking" in capsys.readouterr().out
def test_a_dead_real_stream_does_not_fail_the_node_being_teed():
"""`fluksio serve` runs the engine as a child of the dashboard."""
class Broken:
def write(self, text: str) -> int:
raise BrokenPipeError(32, "Broken pipe")
def flush(self) -> None:
raise BrokenPipeError(32, "Broken pipe")
collected: list[str] = []
tee = logs._Tee(Broken())
with logs.capture(collected.append):
assert tee.write("still teed") == len("still teed")
tee.flush()
assert collected == ["still teed"]
def test_installing_twice_does_not_stack_tees():
logs.install()
once = sys.stdout
logs.install()
assert sys.stdout is once