reconciler: drain stdout to avoid deadlock#2440
Open
ideaship wants to merge 1 commit into
Open
Conversation
run_on_change() never read the /run.sh subprocess pipe and called p.wait() with no timeout, and run() left the pipe undrained when publish=False. In these paths stdout is a subprocess.PIPE that is never consumed, so a /run.sh emitting more than the OS pipe buffer (~64 KB) blocks on write while the parent blocks forever on wait(): a deadlock that hangs the reconciler worker. Always drain the pipe. run() now reads it whether or not the output is forwarded downstream, moving the read loop back outside the publish guard. run_on_change() writes the drained lines to the log (the task publishes nowhere) and p.wait() gains the timeout=60 backstop that run() already uses. Update the affected unit tests to feed real byte streams through the production drain loop and assert that the pipe is consumed, rather than pinning the previous undrained call shape. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Roger Luethi <luethi@osism.tech>
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- In both
runandrun_on_change, consider wrappingio.TextIOWrapper(p.stdout, ...)in a context manager so the wrapper (and underlying pipe) are explicitly closed after draining to avoid relying on GC for cleanup. - In
run_on_change,logger.info(line.rstrip())will strip all trailing whitespace, not just the newline; if preserving message spacing matters, userstrip("\n")instead.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In both `run` and `run_on_change`, consider wrapping `io.TextIOWrapper(p.stdout, ...)` in a context manager so the wrapper (and underlying pipe) are explicitly closed after draining to avoid relying on GC for cleanup.
- In `run_on_change`, `logger.info(line.rstrip())` will strip all trailing whitespace, not just the newline; if preserving message spacing matters, use `rstrip("\n")` instead.
## Individual Comments
### Comment 1
<location path="tests/unit/tasks/test_task_wrappers.py" line_range="545" />
<code_context>
)
mocker.patch("osism.tasks.reconciler.utils.create_redlock", return_value=lock)
proc = mocker.MagicMock()
+ proc.stdout = io.BytesIO(b"")
mocker.patch("osism.tasks.reconciler.subprocess.Popen", return_value=proc)
</code_context>
<issue_to_address>
**issue (testing):** Extend the lock-already-released test to assert stdout is drained and wait uses the timeout, matching the new behavior.
The test now sets `proc.stdout = io.BytesIO(b"")`, but it doesn’t verify stdout is drained or that `wait` uses the timeout. Since `run_on_change` always drains stdout and calls `p.wait(timeout=60)`, please add assertions like `assert proc.stdout.closed` and `proc.wait.assert_called_once_with(timeout=60)` so the lock-already-released path also validates this behavior.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| ) | ||
| mocker.patch("osism.tasks.reconciler.utils.create_redlock", return_value=lock) | ||
| proc = mocker.MagicMock() | ||
| proc.stdout = io.BytesIO(b"") |
There was a problem hiding this comment.
issue (testing): Extend the lock-already-released test to assert stdout is drained and wait uses the timeout, matching the new behavior.
The test now sets proc.stdout = io.BytesIO(b""), but it doesn’t verify stdout is drained or that wait uses the timeout. Since run_on_change always drains stdout and calls p.wait(timeout=60), please add assertions like assert proc.stdout.closed and proc.wait.assert_called_once_with(timeout=60) so the lock-already-released path also validates this behavior.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
run_on_change()starts/run.shwithstdout=subprocess.PIPEbut never reads the pipe and callsp.wait()with no timeout.run()has the same undrained pipe on thepublish=Falsepath. Because the pipe is never consumed, a/run.shthat writes more than the OS pipe buffer (~64 KB) blocks on write while the parent blocks forever onwait()— deadlocking the reconciler worker (run_on_changeunbounded;run(publish=False)bounded only by itswait(timeout=60), so it errors rather than hangs).Fix
run()— always drain stdout by moving the read loop back outside thepublishguard; forwarding viapush_task_output/finish_task_outputstays gated onpublish. This matches the always-drain pattern used byrun_ansible_in_environmentandrun_command.run_on_change()— drain stdout into the log (the task publishes nowhere) and addwait(timeout=60)as a reap backstop, matchingrun().stdout=subprocess.PIPEis retained on both paths — the pipe is required in order to drain it.Tests
The two affected unit tests now feed real byte streams through the production
TextIOWrapperdrain loop and assert the pipe is consumed, rather than pinning the previous undrained call shape.black --checkandflake8: clean🤖 Generated with Claude Code