Skip to content

fix: prevent StreamedLog stop() from hanging on a silent stream - #825

Merged
vdusek merged 1 commit into
masterfrom
fix/streamed-log-sync-stop-hang
May 26, 2026
Merged

vdusek merged 1 commit into
masterfrom
fix/streamed-log-sync-stop-hang

Conversation

@vdusek

@vdusek vdusek commented May 25, 2026

Copy link
Copy Markdown
Contributor

Summary

In StreamedLog (src/apify_client/_streamed_log.py), stop() sets _stop_logging = True and then join()s the streaming thread. The thread is parked inside iter_bytes() on a blocking socket read, so the flag isn't observed until the next chunk arrives or the long-polling server-side timeout (~360s) elapses — stop() can block for minutes on a quiet actor.

Passing a _read_timeout (30s) to _log_client.stream() caps how long iter_bytes() can block, so stop() unblocks within that window. The timeout is exposed as a ClassVar[timedelta] so it can be tuned (or shortened in tests) without monkeypatching a module global. The loop is also wrapped in try/finally so the buffered tail is flushed even if a read times out.

Tradeoff: the sync stream now ends if the actor goes silent for more than 30s (vs. the prior 360s). This is documented on the class attribute and is the intended cost of bounded stop().

A regression test covers the bug.

@vdusek vdusek added adhoc Ad-hoc unplanned task added during the sprint. t-tooling Issues with this label are in the ownership of the tooling team. labels May 25, 2026
@vdusek vdusek self-assigned this May 25, 2026
@vdusek
vdusek requested a review from janbuchar May 25, 2026 14:38
@github-actions github-actions Bot added this to the 141st sprint - Tooling team milestone May 25, 2026
@github-actions github-actions Bot added the tested Temporary label used only programatically for some analytics. label May 25, 2026
@codecov

codecov Bot commented May 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 94.68%. Comparing base (e794411) to head (99279c5).

Files with missing lines Patch % Lines
src/apify_client/_streamed_log.py 90.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master     #825       +/-   ##
===========================================
+ Coverage   83.46%   94.68%   +11.22%     
===========================================
  Files          48       48               
  Lines        5043     5044        +1     
===========================================
+ Hits         4209     4776      +567     
+ Misses        834      268      -566     
Flag Coverage Δ
integration 93.16% <90.00%> (?)
unit 83.46% <90.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@vdusek
vdusek requested review from janbuchar and removed request for janbuchar May 25, 2026 14:39
@vdusek
vdusek merged commit c15cb1b into master May 26, 2026
27 checks passed
@vdusek
vdusek deleted the fix/streamed-log-sync-stop-hang branch May 26, 2026 06:42
vdusek added a commit that referenced this pull request Sep 16, 2026
…or call (#1027)

### Description

`actor.call()` with the default `logger='default'` could raise on a run
that had already succeeded. A status poll that failed after retries left
the exception on the watcher task; `stop()` then cancelled an
already-finished task and `await`ed it, re-raising past `except
asyncio.CancelledError`. The sync twin leaked the same failure out of
its thread.

- Both watcher loops now use the handler `StreamedLog._stream_log`
already had: warn on `is_timeout_error`, `logger.exception` otherwise. A
failed poll gets reported and the run's own result stands.
- The sync watcher's thread is a daemon, so a watcher whose loop never
terminates cannot hold up interpreter shutdown.
- `StreamedLog.stop()` closes the stream response and caps its join at
`_stop_timeout_s` (5s), where it used to join unbounded on a stream that
may not speak for hours.
- `start()` on both sync classes checks `is_alive()`, so a handle left
by a thread that outlived its stop does not block a restart.

The close alone is not enough. On Impit 0.13.2 `Response.close()`
returns immediately and sets `is_closed=True`, yet a reader blocked in
`iter_bytes()` stays blocked, and closing the `Client` does not release
it either. The close is kept because it is correct for a custom HTTP
client, and the capped join is what makes `stop()` return.

### Why `stop()` is bounded this way

- #825 bounded `stop()` by requesting the log stream with a 30s read
timeout.
- #944 removed that: Impit applies `timeout` to the whole request,
streamed body included, so a run logging past the bound was cut off
mid-stream with `impit.TimeoutException` (#945). The stream has used
`no_timeout` since, with the unbounded `stop()` recorded as a known
limitation.
- This PR caps the join and closes the response, leaving the stream
request alone. `_stream_timeout` stays `no_timeout`, still asserted by
`test_streamed_log_sync_requests_stream_with_no_timeout`, so #945 cannot
regress. What it fixes is the case #944 left open: a manual `stop()` on
a stream gone quiet.

Trade-off: a thread outliving the 5s cap keeps running as a daemon, so
`stop()` can return before the tail is flushed. On the `actor.call()`
path the run's EOF ends the thread first, measured at 0.000s over
repeated `apify/hello-world` runs.

### Test plan

- 8 unit tests and an integration test that fails a real run's
background status polls, each red before the fix.

*✍️ Drafted by Claude Code*
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

adhoc Ad-hoc unplanned task added during the sprint. t-tooling Issues with this label are in the ownership of the tooling team. tested Temporary label used only programatically for some analytics.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants