Skip to content

fix(sdk/python): stop the structured log stdout write from blocking the event loop - #1066

Merged
AbirAbbas merged 6 commits into
mainfrom
fix/985-log-write-queue
Sep 21, 2026
Merged

AbirAbbas merged 6 commits into
mainfrom
fix/985-log-write-queue

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

_emit_structured_record and the plain-log handler both write to stdout inline, on whatever
thread called them. In an agent node that thread is the event loop, and sys.stdout is the
_TeeTextIO wrapper, so one emit takes the tee's lock, writes, appends to the process-log
ring, and then flushes — the real OS write. If whoever is reading the other end of the pipe
stops draining it (a busy tty, a piped consumer, a kubectl logs reader that stalls) the
write blocks in the kernel and the whole loop stops with it. Size bounding (#1002/#1028) does
not help: this is about the pipe, not the payload.

This adds agentfield/log_writer.py — a bounded FIFO with one daemon writer thread that all
SDK logger stdout writes go through. Deferral only engages where it can actually help: the
emit happens on a running event loop and the destination has a real file descriptor. Off the
loop, or into an in-memory capture, the write stays inline and immediately visible exactly as
before. Under back-pressure the queue discards its oldest pending lines rather than blocking
the producer, and the writer emits one log.dropped record naming the count, so loss is never
silent. AGENTFIELD_LOG_QUEUE=false restores today's behaviour.

Two supporting changes fall out of it:

  • _DynamicStdoutHandler no longer creates a logging handler lock. Handler.handle() holds
    that lock across emit(), so a synchronous thread blocked inline on a stalled stdout would
    otherwise still stall an event-loop caller before it ever reached the queue. The writer
    provides the serialization now.
  • node_logs registers an os.register_at_fork(after_in_child=...) hook that gives the
    installed tees, the ring and the follower registry fresh locks in a forked child. A tee's
    write lock is held across the blocking write, so a fork at the wrong moment leaves the child
    with a locked mutex and no owner and deadlocks its stdout forever. That hazard predates this
    PR, but a dedicated writer thread holding the lock for the length of a stall makes it easy
    to hit, so it is fixed here.

@AbirAbbas — this reverses the "not doing a FIFO writer" call in
#985 (comment), so here are the
numbers you asked for, and what the design does about each of the four objections:

  • capsys / StringIO capture in tests breaks — deferral requires stream.fileno() to succeed.
    Under pytest capture it raises io.UnsupportedOperation, so every captured write stays
    inline. The existing capsys-based logger tests pass unchanged.
  • last lines lost on os._exit — an atexit drain bounded by
    AGENTFIELD_LOG_QUEUE_FLUSH_SECONDS (2 s) covers normal exit; os._exit still bypasses it
    and the docs say so, with AGENTFIELD_LOG_QUEUE=false as the answer for a process that needs
    inline writes before such an exit.
  • ordering between structured and plain lines goes — both paths go through the same single
    writer, and the backlog check keeps a synchronous caller behind anything already queued, so
    their relative order holds. Ordering against a caller's own bare print() does not, and that
    is documented.
  • fork safety — handled in both modules, as above.

Still inline, and worth knowing: handlers this SDK does not own (uvicorn's access log, a
caller's own print) write to stdout themselves and can still block on a stalled pipe. This
change owns the SDK logger's writes.

Validation

A real control plane (af server, local mode, isolated HOME) and a real Python agent node
started with its stdout on a pipe. A collector thread drains that pipe, then stops draining for
N seconds while the driver calls a reasoner that logs 400 structured records from the event
loop and, concurrently, calls a second silent reasoner on the same loop.

collector reasoner latency (main) reasoner (this PR) concurrent 2nd reasoner (main) (this PR)
draining 1.26 s 1.13 s — —
stalled 8 s 9.14 s 1.26 s 7.06 s 0.19 s
stalled 15 s 15.63 s 1.06 s 14.13 s 0.52 s

On main the reasoner's latency tracks the stall 1:1 and the second reasoner is stuck behind
it. With the queue, latency is flat no matter how long the consumer is away, and all 800
mirrored lines still arrive once it comes back.

Overflow, same setup: 3000 records against a 12 s stall overflows the 1024-line queue, the
reasoner still returns in 8.4 s (7.6 s with the collector healthy), and stdout carries exactly
one marker — {"level":"warning","event_type":"log.dropped","message":"dropped 1918 structured log lines (stdout back-pressure)","attributes":{"dropped":1918}} — ahead of the surviving
lines.

A narrower in-process probe (400 records, 6 s stall, a 10 ms asyncio.sleep watchdog) shows
the same thing at loop level: worst watchdog lag 5369 ms on main, low single-digit ms here.

Gates run locally on the versions CI pins: ruff check ., mypy --config-file mypy.ini agentfield/, and the full ./scripts/run_pytest.sh suite.

Fixes #985

🤖 Generated with Claude Code

AbirAbbas and others added 4 commits September 21, 2026 13:29
…ines

A single daemon thread drains a bounded FIFO of (stream, line) pairs.
Deferral only engages where it can help — the caller is on a running event
loop and the destination has a real file descriptor — so synchronous callers
and in-memory captures keep writing inline and stay immediately visible.

Under back-pressure the queue discards its oldest pending lines instead of
blocking the producer, and the writer emits one log.dropped record per
destination naming the count, so loss is never silent. An atexit drain
bounded by AGENTFIELD_LOG_QUEUE_FLUSH_SECONDS covers normal shutdown, and an
at-fork hook gives the child fresh state.

Refs #985

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nt loop

_emit_structured_record printed inline on the calling thread, which in an
agent node is the event loop, into the _TeeTextIO wrapper over a real pipe.
A consumer that stops draining that pipe froze the whole loop for the
duration — heartbeats, in-flight reasoners and the control-plane client
alike. Size bounding did not help; the stall is the pipe, not the payload.

Both stdout paths now go through the bounded writer, which also keeps their
relative order. _DynamicStdoutHandler additionally drops its logging handler
lock: Handler.handle() holds that lock across emit(), so a synchronous
thread blocked inline on a stalled stdout would otherwise still stall an
event-loop caller before it reached the queue. The writer serializes instead.

Refs #985

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_TeeTextIO holds its write lock across the blocking write to the original
stream. A fork while another thread holds it leaves the child with a mutex
that is locked and has no owner, so every later write in the child deadlocks
and its stdout is gone for good.

An after_in_child hook now gives each installed tee a fresh lock, clears the
partial line inherited mid-write, re-creates the ring and follower locks and
drops the parent's follower queues. There is deliberately no before= hook:
acquiring those locks ahead of the fork would make fork() itself wait on a
stalled pipe.

The hazard predates this branch, but a dedicated writer thread that can hold
the lock for the length of a consumer stall makes it easy to hit.

Refs #985

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AGENTFIELD_LOG_QUEUE, AGENTFIELD_LOG_QUEUE_SIZE and
AGENTFIELD_LOG_QUEUE_FLUSH_SECONDS, alongside the existing logging
variables: when deferral engages, the drop-oldest policy and its log.dropped
marker, that os._exit bypasses the exit flush, and that ordering against a
caller's own print() is no longer guaranteed.

Refs #985

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AbirAbbas
AbirAbbas requested a review from a team as a code owner September 21, 2026 17:30
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.0 KB - 0.32 µs -9% ✓ ✓

✓ No regressions detected

@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.80% 87.40% ↑ +0.40 pp 🟡
sdk-go 93.20% 92.00% ↑ +1.20 pp 🟢
sdk-python 94.72% 93.73% ↑ +0.99 pp 🟢
sdk-typescript 91.76% 90.42% ↑ +1.34 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.88% 85.75% ↑ +0.13 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 0 — ➖ no changes
sdk-go 0 — ➖ no changes
sdk-python 0 — ➖ no changes
sdk-typescript 0 — ➖ no changes
web-ui 0 — ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

AbirAbbas and others added 2 commits September 21, 2026 14:20
Setting logging.Handler.lock to None relied on handle() calling
self.acquire(), which skips a falsy lock. Python 3.13 changed handle() to
`with self.lock:`, so None raises TypeError: 'NoneType' object does not
support the context manager protocol — every plain log line on 3.13 died in
the handler, and a test whose synchronous writer thread was killed that way
hung CI rather than failing.

Use a no-op lock object instead: it satisfies both the acquire/release and
the context-manager protocols, and keeps the property we want, which is that
nothing serializes on the handler while the bounded writer already does.

Refs #985

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
These helpers block on a pipe read or a lock on purpose. As non-daemon
threads, a failing assertion left them alive and the interpreter waited for
them at exit, so a test failure presented as a hung CI job instead of a
failure. As daemons the session reports the failure and exits.

Refs #985

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AbirAbbas
AbirAbbas merged commit 00c8844 into main Sep 21, 2026
29 checks passed
@AbirAbbas
AbirAbbas deleted the fix/985-log-write-queue branch September 21, 2026 20:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Python SDK] Structured logs, event loop and Postgres instance

1 participant