Skip to content

fix(workflow): settle a script run on its exit, not on pipe EOF - #895

Open
harshitwandhare wants to merge 1 commit into
awslabs:mainfrom
harshitwandhare:fix/script-drain-after-exit
Open

harshitwandhare wants to merge 1 commit into
awslabs:mainfrom
harshitwandhare:fix/script-drain-after-exit

Conversation

@harshitwandhare

Copy link
Copy Markdown
Contributor

Fixes #894

What was broken

A script workflow that starts a background process and exits 0 was not treated as finished at its exit. The background process inherits the script's stdout and stderr, so both pipes stay open after the script is gone, and _drive_process waited for them:

  • Python 3.11+: process.wait() started before the exit also waits for pipe EOF, so the run sat out all of WORKFLOW_SCRIPT_TIMEOUT and was journaled FAILED, kind=timeout, with its CAO_WORKFLOW_OUTPUT dropped.
  • Python 3.10: wait() returns at the exit, but the asyncio.gather(*drain) after it had no bound, so the run did not settle until the helper exited.

Measurements and the asyncio timings for all three versions are in #894.

The fix

  • _wait_for_exit races process.wait() against process.returncode, which asyncio sets at the exit on 3.10, 3.11 and 3.12 whether or not the pipes are open. _await_exit_within_bound now wraps it instead of wait().
  • After the exit, the drain gets WORKFLOW_SCRIPT_TERM_GRACE. If the pipes are still open after that, the readers are cancelled, the output already read is kept, and a warning says the pipes were still open.

A successful run now settles within WORKFLOW_SCRIPT_TIMEOUT + WORKFLOW_SCRIPT_TERM_GRACE, the same envelope as the timeout arm, so the invariant in constants.py holds again. A script that closes its pipes normally is unaffected: the drain finishes at once, as before.

The background process itself is left running, the same as today, since _terminate signals the script's own process and not its group.

Tests

  • test_exit_with_pipes_held_open_completes, parametrized over the two wait() behaviours (returns at exit, holds for the pipes), with a fake process whose streams never reach EOF.
  • test_real_background_child_holding_stdout_does_not_hold_the_run in the e2e script-runner suite, with a real helper that outlives the script.

All three fail on main and pass with the fix, on each CI Python:

Python main + tests only this branch
3.10.21 3 failed 3 passed
3.11.16 3 failed 3 passed
3.12.3 3 failed 3 passed

Verification

In WSL Ubuntu 24.04 on 3.12, with uv sync --locked --all-extras --dev:

  • the ci.yml unit command (pytest test/ examples/workflow/tests/ --ignore=test/providers/test_kiro_cli_integration.py --ignore=test/e2e -m "not e2e" --cov=...): 14184 passed / 1 failed on main at fd5113ea, 14187 passed / 0 failed on this branch (the 14185 tests main ran plus the 2 new parametrized cases). The one main failure, test_fifo_reader.py::TestReaderThreadLifecycle::test_data_received_across_writer_reconnects, is a timing flake under load: it passed 5 of 5 run on its own on main
  • pytest test/e2e/script_runner -m e2e: 6 passed
  • pytest test/test_http_only_boundary.py: 2 passed
  • black, isort and scripts/validate_markdown_links.py clean
  • mypy: 153 errors in 17 files on both trees, so none added
  • the added lines in script_runner.py are all covered

trivy was not installed in my environment, for both runs.

CHANGELOG entry added under Unreleased / Fixed.

A script that starts a background process and exits 0 left both pipes
held open by that process. On Python 3.11+ process.wait() started before
the exit waits for pipe EOF as well, so the run sat out the whole
WORKFLOW_SCRIPT_TIMEOUT and was journaled FAILED, kind=timeout with its
output dropped. On 3.10 wait() returned at the exit, but the drain after
it had no bound, so the run did not settle until the helper exited.

Wait for the exit with wait() raced against returncode, which asyncio
sets at the exit on every supported version, then give the drain
WORKFLOW_SCRIPT_TERM_GRACE and keep what was read, with a warning. A
successful run now settles within WORKFLOW_SCRIPT_TIMEOUT +
WORKFLOW_SCRIPT_TERM_GRACE, the same envelope as the timeout arm.

Fixes awslabs#894

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The implementation has a timeout-boundary race and retains pipe resources after cancelling readers.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity · 1 Low severity

Open (3)
What changed in this PR

Fixes script workflows hanging when background processes retain stdout/stderr pipes.

Changes:

  • Detects script exit independently of pipe EOF.
  • Bounds post-exit pipe draining and preserves captured output.
  • Adds unit, end-to-end, and changelog coverage.
File Description
src/​cli_agent_orchestrator/​services/​script_runner.py Adds exit polling and bounded pipe draining.
test/​services/​test_script_runner.py Adds held-pipe regression tests.
test/​e2e/​script_runner/​test_script_runner_e2e.py Tests a real background helper.
CHANGELOG.md Documents the fix.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +347 to 348
await asyncio.wait_for(_wait_for_exit(process), timeout=timeout)
except asyncio.TimeoutError as e:
Comment on lines +1121 to +1126
await asyncio.wait_for(asyncio.gather(*drain), timeout=WORKFLOW_SCRIPT_TERM_GRACE)
except asyncio.TimeoutError:
drain_warnings.append(
f"stdout/stderr were still open {WORKFLOW_SCRIPT_TERM_GRACE}s after the script "
"exited, most likely held by a process it started; output read up to then was kept"
)
Comment on lines +182 to +198
``returncode`` is set from the start, as asyncio sets it when the process
exits. ``wait_holds_for_pipes`` picks the ``wait()`` behaviour: on Python
3.10 it returns at exit, and from 3.11 a ``wait()`` started before the exit
does not return until the pipes reach EOF, which here is never.
"""

def __init__(self, *, stdout: bytes, wait_holds_for_pipes: bool):
self.returncode: Optional[int] = 0
self.stdout = _HeldOpenStream(stdout)
self.stderr = _HeldOpenStream(b"")
self._wait_holds_for_pipes = wait_holds_for_pipes
self.signals: List[str] = []

async def wait(self) -> int:
if self._wait_holds_for_pipes:
await asyncio.Event().wait()
return 0
@codecov-commenter

codecov-commenter commented Oct 7, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@fd5113e). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #895   +/-   ##
=======================================
  Coverage        ?   92.69%           
=======================================
  Files           ?      244           
  Lines           ?    41354           
  Branches        ?        0           
=======================================
  Hits            ?    38332           
  Misses          ?     3022           
  Partials        ?        0           
Flag Coverage Δ
unittests 92.69% <100.00%> (?)

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

☔ View full report in Codecov by Harness.
📢 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.

This branch has not been deployed

No deployments
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.

Script workflow that leaves a background process holding stdout is reported as a timeout (3.11+) or never settles (3.10)

4 participants