Skip to content

Skip the buffer join in respond() when no watchers are attached - #1083

Open
aryansk wants to merge 1 commit into
pyinvoke:mainfrom
aryansk:skip-buffer-join-no-watchers-1079
Open

Skip the buffer join in respond() when no watchers are attached#1083
aryansk wants to merge 1 commit into
pyinvoke:mainfrom
aryansk:skip-buffer-join-no-watchers-1079

Conversation

@aryansk

@aryansk aryansk commented Aug 16, 2026

Copy link
Copy Markdown

What

Fixes #1079Runner.respond() no longer pays an O(n²) join cost when no StreamWatcher is attached, which is the default/common case.

Why

respond() joins the entire accumulated capture buffer on every output chunk, then feeds it to the watcher loop. With no watchers registered (the normal case), that join result is unused — but it still runs, making output handling quadratic in total output size. The reporter measured ~2 hours to process a 300MB pytest output; Channel.sendall-style slicing was fixed elsewhere for the same reason (#892 reference in the issue).

Local benchmark, simulating _handle_output's append-then-respond loop with 4KB chunks and no watchers:

chunks total before after
2000 8MB 0.38s ~0.1ms
4000 16MB 1.88s ~0.2ms
8000 31MB 10.35s 0.5ms

How

Early-return from respond() when self.watchers is empty. Behavior is identical — the join's result is only consumed inside the watcher loop, so skipping it when there are no watchers cannot change observable behavior. Watcher-driven autoresponding (the watchers kwarg of run) is untouched.

Tests

Added Runner_.watchers.no_watchers_skips_the_buffer_join in tests/runners.py: a non-string buffer element is the canary — pre-fix, respond(["chunk", 42]) raised TypeError from the join; post-fix it is a no-op. Full suite: 860 passed; the 114 failures are pre-existing on main in this environment (pty/terminal thread handling), identical with and without this change. Ruff + black clean (pre-existing repo-wide drift excluded).

🤖 Generated with Codebuff

respond() joins the entire accumulated capture buffer on every output
chunk, even when no StreamWatcher is registered -- the common case.
That is O(n^2) in total output: with a few hundred MB of subprocess
output, runs took hours (the reporter measured ~2h for 300MB). Bail out
early when self.watchers is empty, which preserves behavior exactly
(the join result is only consumed by the watcher loop) and drops the
hot-path cost to a no-op. A watcher-free run of 8000 x 4KB chunks goes
from ~10.4s to ~0.5ms.

Fixes pyinvoke#1079

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
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.

Runner.respond uses join leading to slowness

1 participant