From 912cc5242d0819068d453429ddee938f1f7d338d Mon Sep 17 00:00:00 2001 From: "Aryan Singh K." <70511529+aryansk@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:47:05 +0530 Subject: [PATCH] Skip the buffer join in respond() when no watchers are attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #1079 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- invoke/runners.py | 5 +++++ tests/runners.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/invoke/runners.py b/invoke/runners.py index c59481399..9c88dae45 100644 --- a/invoke/runners.py +++ b/invoke/runners.py @@ -944,6 +944,11 @@ def respond(self, buffer_: List[str]) -> None: .. versionadded:: 1.0 """ + # Nothing to do unless at least one watcher is attached; bail early so + # the (potentially O(n^2)) join below never runs in the common case of + # running without watchers while a subprocess emits lots of output. + if not self.watchers: + return # Join buffer contents into a single string; without this, # StreamWatcher subclasses can't do things like iteratively scan for # pattern matches. diff --git a/tests/runners.py b/tests/runners.py index f3a49dd20..977fba860 100644 --- a/tests/runners.py +++ b/tests/runners.py @@ -982,6 +982,16 @@ def nothing_is_written_to_stdin_by_default(self): self._runner(klass=klass).run(_) assert not klass.write_proc_stdin.called + def no_watchers_skips_the_buffer_join(self): + # Regression test for #1079: respond() used to join the whole + # accumulated capture buffer on every chunk even with no watchers + # attached, an O(n^2) cost that made runs with large outputs + # (hundreds of MB) take hours. With no watchers it must return + # without touching the buffer -- the canary here is a non-string + # element, which raises TypeError if a join ever runs. + runner = self._runner() + runner.respond(["chunk", 42]) + def _expect_response(self, **kwargs): """ Execute a run() w/ ``watchers`` set from ``responses``.