Skip to content

fix(sandbox): settle PTY output before cleanup - #4738

Open
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output
Open

fix(sandbox): settle PTY output before cleanup#4738
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output

Conversation

@seratch

Copy link
Copy Markdown
Member

This pull request fixes PTY output settlement and supersedes #4572 and #4724. PTY collectors now re-drain output at timeout boundaries, carry only complete valid UTF-8 sequences across read windows, and make bounded replacement progress for invalid E0, ED, F0, and F4 prefixes.

Terminal cleanup now follows a collector-owned settled output_closed fact across local, Docker, E2B, Cloudflare, Modal, Blaxel, and Daytona adapters, so exit visibility cannot drop queued bytes or carried suffixes.

@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Aug 28, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-08-31T02:44:42.234416Z2b2d175New commits
🔒 Security ReviewCompleted2026-08-31T02:44:01.462995Z2b2d175New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:21c32b9985

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadsrc/agents/sandbox/session/pty_output.py Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch
seratchforce-pushed the fix/settle-pty-output branch from 21c32b9 to 41be368CompareAugust 28, 2026 10:12

@fscfede-beepfscfede-beep left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I re-audited the current 41be368c head specifically around cancellation ownership. Two cancellation boundaries can still lose bytes that this PR now intends to carry across PTY windows.

await wait_for_output(remaining_s)
else:
try:
await asyncio.wait_for(output_notify.wait(), timeout=remaining_s)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancellation can drop a UTF-8 prefix that a previous window deliberately carried for this surviving session. Deterministic ordering on current head: first window ends with b"\xc3", so _drain_and_carry_incomplete_suffix() leaves [b"\xc3"] on output_chunks; the next collection drains that lead byte into local output, reaches this output_notify wait, and is cancelled. There is no outer cancellation handler, so the deque is now empty. If b"\xa9" arrives later, the next/final collection sees the continuation alone and returns instead of é.

This is in-scope for this PR: before the new cross-window carry, a cancelled call could abandon its own drained window, but it could not consume persistent UTF-8 state intentionally handed forward by an earlier successful call and thereby corrupt later output.

The narrow fix is to make the drained local buffer transactional. Wrap collection/settlement in try/except asyncio.CancelledError; if output is non-empty, restore it synchronously to the front of output_chunks before re-raising (or keep ownership on the entry until commit). appendleft(bytes(output)) preserves these older bytes ahead of anything a producer queued while the call was running. Regression: cancel the second window while it is waiting here after draining the requeued b"\xc3"; assert the lead is back in the deque and a later b"\xa9" completes é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I turned this boundary into an executable control-flow reference against the 41be368c ownership shape. The current-shape repro drains a carried b"\xc3", cancels while the next window is waiting, and deterministically leaves the deque empty; the fixed reference restores the drained local buffer synchronously with appendleft(bytes(output)), then a later b"\xa9" reconstructs é. The focused reference suite also covers the Modal transfer below: 6/6 PASS (3 tests prove current bad behavior, 3 prove the proposed ownership repairs).

The narrow shared-collector patch contract is to put the existing collection + final settlement/drain/carry phase under try/except asyncio.CancelledError; if local output is non-empty, synchronously return it to the front of output_chunks before re-raising. The handler needs to cover cancellation from poll_output, waits, settle_output, and the final drain/carry lock acquisition. I have not run the upstream suite for this patch and am not opening a competing PR.

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Modal has the same survivor invariant one layer earlier: a successful stream read is consumed from the provider before poll_output() commits it to entry.output_chunks. Example on this branch: a previous window has carried b"\xc3" in entry.output_chunks; this call reads the continuation b"\xa9" from stdout, then is cancelled during this stderr await. The continuation lives only in local chunks, so it is lost while the older lead byte remains durable. A later call can then pair that lead with unrelated output or eventually replace it.

The exit drain has the same shape internally: _drain_modal_stream() accumulates consumed stream items in its own local bytearray across further awaits before returning them to poll_output(). Catching cancellation only in the outer shared collector therefore cannot recover bytes already removed from Modal's stream.

Please make each Modal ownership transfer cancellation-safe: either commit every successfully consumed stream chunk to entry-owned state before the next await, or catch CancelledError in both poll_output() and _drain_modal_stream() and synchronously restore their local consumed bytes to entry.output_chunks before propagating cancellation. Avoid an awaited lock acquisition as the only copy's next step unless cancellation around that acquisition also restores it. A regression should consume the UTF-8 continuation from stdout, block the following stderr read, cancel there, and verify the continuation remains entry-owned and the next collection reconstructs é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I also exercised this Modal ownership transfer independently. Current-shape repro: stdout consumes the continuation b"\xa9", stderr blocks, caller cancellation arrives, and the continuation never reaches entry.output_chunks; a separate exit-drain repro consumes b"tail", cancels on the next read, and loses that local buffer too. Both are deterministic.

The fixed reference passes by (1) moving each successful stdout/stderr read into local chunks immediately before the next await, (2) catching CancelledError in poll_output and synchronously returning any locally owned chunks to entry.output_chunks, clearing local ownership immediately after a normal queue commit to avoid replay, and (3) giving _drain_modal_stream the same cancellation-return rule for its bytearray. Together with the shared collector repair, the focused reference suite is 6/6 PASS. No provider integration execution or upstream patch application is claimed.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Cross-link for collision/maintainer coordination: #4745 was opened later against the same PTY cross-window UTF-8 / settlement area and has since accumulated a different implementation plus several cancellation/finalization fixes. I’m reviewing both rather than opening a third implementation. The two current cancellation findings I left on this PR (3885258586, 3885258589) are specific to this branch’s carry/Modal ownership boundaries; #4745 currently avoids them with different transaction state, but has had its own finalization lifecycle issues. Recommend treating #4738 and #4745 as competing solutions and selecting one architecture rather than merging them independently.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Selective salvage after comparing #4745 against current 41be368c: I do not think its whole patch should be ported here. Two of the three potentially useful pieces are already covered or architecture-specific: restricted E0/ED/F0/F4 prefix handling is already tested here, and #4745's source_text + final tail truncation repair addresses a post-collection tail path this branch does not use.

One test idea is still useful here: a controlled Unix lifecycle regression where process.returncode is already visible but the output pump still owns the continuation of a split UTF-8 character. Hold the pump before it appends the continuation, assert the first collection/finalization keeps the session live and retains the lead byte, release the pump, then assert the next update returns é and closes. The current implementation looks correct by inspection because _watch_process_exit sets output_closed only after gather(*pump_tasks), but tests/sandbox/test_unix_local.py does not currently pin that ordering.

I would add that regression only after/alongside the two cancellation ownership fixes already posted in 3885258586 and 3885258589. No third competing PR from me.

@HuzaifaChaudary

Copy link
Copy Markdown

hi @seratch. i had a pull request in this area, #4745, which i have just closed in favour of this one. it predates mine and covers the same ground, so there is nothing to weigh up there.

@fscfede-beep reviewed both and suggested one thing from mine might still be worth having, so i am leaving it here as an idea rather than a patch. take it or ignore it.

this pr has the producer drained output_closed design already. the test below pins the ordering it protects, a process reaped while a pump still owns the continuation, which is the case where finalising on the exit code alone silently turns é into :

entry=_UnixPtyProcessEntry(process=process, tty=True) # returncode 0# output_closed deliberately NOT set, the pump still holds the continuationentry.output_chunks.append("é".encode()[:1])
# first update must leave the session alive with the lead byte still queuedassertfirst.process_id==1assertfirst.exit_codeisNoneassertlist(entry.output_chunks) == ["é".encode()[:1]]
# then the pump delivers and closesentry.output_chunks.append("é".encode()[1:])
entry.output_closed.set()
assertfinal.output.decode("utf-8") =="é"assertfinal.process_idisNone

what made me think it earns its place is that it fails for the right reason. swapping the predicate back to entry.process.returncode on its own gives:

PtyExecUpdate(process_id=None, output=b'\xef\xbf\xbd', exit_code=0, original_token_count=None)

so it catches a future tidy up that undoes the predicate, which is a one line change that reads like cleanup. the full version is in the closed branch at HuzaifaChaudary/openai-agents-python@78b5c4d, tests/sandbox/test_unix_local.py::TestUnixLocalPty::test_session_is_not_finalized_while_a_pump_still_holds_output.

happy to open it as a small test only pull request against this branch if you want it, or to leave it entirely. no need to reply if not.

@HuzaifaChaudary

Copy link
Copy Markdown

@seratch, short follow up to my note above and then i will stop.

the two cancellation findings @fscfede-beep raised on this pr are the same two i hit and fixed on the branch i closed, so there is working tested code for both if it saves you deriving them again. commits are on HuzaifaChaudary/openai-agents-python, against my own shape rather than yours, so they are a reference not a patch.

shared collector, 2070572e. exactly the contract he describes:

exceptasyncio.CancelledError:
ifoutput:
output_chunks.appendleft(bytes(output))
raise

modal, bf682c80. the whole buffer goes to entry owned state, carried tail and new reads together, with the try covering both stream reads, the exit poll, the sleep, the drains and the final poll. assigning rather than appending is what stops the carried tail replaying, since chunks already starts with it.

each has a regression that fails on the commit before it. the modal one consumes the continuation off the stream, cancels on the next await, and asserts both halves are still entry owned.

one thing worth flagging, because it bit me right after i fixed these. once the tail flush moved in front of the registry pop, cancelling during the drain skipped _terminate_pty_entry entirely, and since the entry was already out of the map nothing could clean it up later. on blaxel that leaves a websocket and an aiohttp.ClientSession open. 78b5c4d0 puts the drain in a try with the terminate in the finally. worth checking whichever order this branch lands on.

that plus the blocked pump test above is everything i have. no reply needed, and i am not opening anything here.

Co-authored-by: Henry Su <henrysu4707@gmail.com>
Co-authored-by: ayaangazali <ayaangazali.work@gmail.com>
@seratch
seratchforce-pushed the fix/settle-pty-output branch from 41be368 to 2b2d175CompareAugust 31, 2026 02:39

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2b2d1750cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound the second Modal read by the yield deadline

When Modal exposes the supported fallback read interface and both stdout and stderr reads take their 0.2-second timeout, a requested 250 ms yield still takes roughly 400 ms because the stderr read starts unconditionally after stdout has already consumed most or all of the deadline. The fresh evidence in this revision is that the new outer deadline guard skips later polls but does not recheck the deadline between these two sequential reads; check the remaining time before starting stderr or bound each read by the remaining yield window.

Useful? React with 👍 / 👎.

# the close signal that authorizes collector settlement and PTY removal.
async with entry.output_lock:
pass
entry.output_closed.set()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not close E2B output after an indeterminate wait failure

When handle.wait() fails with a transport or runtime exception that has no exit_code, the process may still be running, but this unconditional close signal makes every subsequent output collection return immediately and makes capacity pruning classify the entry as safely settled. That prevents normal long-poll behavior and can cause a still-running process to be preferentially pruned; publish output_closed only after a successful wait or a recognized terminal exception, or retain and surface the indeterminate waiter failure separately.

Useful? React with 👍 / 👎.

@ErenAta16ErenAta16 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.

Flagging the neighbourhood rather than the diff, since three open PRs are editing
these files at once and only one of the three overlaps is a real duplicate.

@Hughhhhcoder's #4750 and @mikemikimike's #4751 both landed the day after this one.
Reading the source changes, the three are addressing different defects:

#4738 threads output_closed through _collect_pty_output output settled before cleanup
#4750 except Exception -> BaseException in pty startup fds leak on cancellation
#4751 wraps _terminate_pty_entry in a cancellation shield teardown aborts on cancellation

So they are complementary, and none of them subsumes another. All three touch
sandbox/sandboxes/unix_local.py, at lines 354, 391/463 and 400/442 respectively,
which is close enough to be worth knowing about but far enough apart that the
hunks should not fight.

The real duplicate is the helper, not the call sites.#4750 adds
_settle_pty_cleanup as a module function in sandbox/session/pty_types.py, and
#4751 adds _settle_pty_cleanup as a method on BaseSandboxSession in
sandbox/session/base_sandbox_session.py. Same name, same shield-in-a-loop
algorithm, same completion.result() then task.result() sequence. Merging both
leaves the SDK with two of them.

They are not equivalent, and the difference shows up when cleanup itself fails
while the caller is being cancelled:

cleanup raises, caller cancelled
#4750 -> CancelledError propagates task.cancelled() = True
#4751 -> RuntimeError propagates task.cancelled() = False

#4750 gives the caller's cancellation priority over the cleanup error; #4751
reaches task.result() before its caller_cancellation check, so the cleanup
exception wins and the CancelledError is dropped. A task that was asked to stop
and then reports cancelled() is False is the case wait_for and TaskGroup
both rely on, so I would take #4750's ordering whichever module the helper ends
up living in.

One completeness note that may save a round trip on #4750: the
except Exception -> except BaseException change there is the only site of its
shape. I walked the AST of everything under sandbox/ and
extensions/sandbox/, looking for a try whose body contains an await and
whose sole handler is except Exception while closing a descriptor, and
unix_local.py:357 is the single match. entries/artifacts.py:714 looks similar
but its try body is fully synchronous, so Exception is sufficient there and it
is not an outlier.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@seratch@fscfede-beep@HuzaifaChaudary@ErenAta16
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(sandbox): settle PTY output before cleanup by seratch · Pull Request #4738 · openai/openai-agents-python · GitHub
Skip to content

fix(sandbox): settle PTY output before cleanup - #4738

Open
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output
Open

fix(sandbox): settle PTY output before cleanup#4738
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output

Conversation

@seratch

Copy link
Copy Markdown
Member

This pull request fixes PTY output settlement and supersedes #4572 and #4724. PTY collectors now re-drain output at timeout boundaries, carry only complete valid UTF-8 sequences across read windows, and make bounded replacement progress for invalid E0, ED, F0, and F4 prefixes.

Terminal cleanup now follows a collector-owned settled output_closed fact across local, Docker, E2B, Cloudflare, Modal, Blaxel, and Daytona adapters, so exit visibility cannot drop queued bytes or carried suffixes.

@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Aug 28, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-08-31T02:44:42.234416Z2b2d175New commits
🔒 Security ReviewCompleted2026-08-31T02:44:01.462995Z2b2d175New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:21c32b9985

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadsrc/agents/sandbox/session/pty_output.py Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch
seratchforce-pushed the fix/settle-pty-output branch from 21c32b9 to 41be368CompareAugust 28, 2026 10:12

@fscfede-beepfscfede-beep left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I re-audited the current 41be368c head specifically around cancellation ownership. Two cancellation boundaries can still lose bytes that this PR now intends to carry across PTY windows.

await wait_for_output(remaining_s)
else:
try:
await asyncio.wait_for(output_notify.wait(), timeout=remaining_s)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancellation can drop a UTF-8 prefix that a previous window deliberately carried for this surviving session. Deterministic ordering on current head: first window ends with b"\xc3", so _drain_and_carry_incomplete_suffix() leaves [b"\xc3"] on output_chunks; the next collection drains that lead byte into local output, reaches this output_notify wait, and is cancelled. There is no outer cancellation handler, so the deque is now empty. If b"\xa9" arrives later, the next/final collection sees the continuation alone and returns instead of é.

This is in-scope for this PR: before the new cross-window carry, a cancelled call could abandon its own drained window, but it could not consume persistent UTF-8 state intentionally handed forward by an earlier successful call and thereby corrupt later output.

The narrow fix is to make the drained local buffer transactional. Wrap collection/settlement in try/except asyncio.CancelledError; if output is non-empty, restore it synchronously to the front of output_chunks before re-raising (or keep ownership on the entry until commit). appendleft(bytes(output)) preserves these older bytes ahead of anything a producer queued while the call was running. Regression: cancel the second window while it is waiting here after draining the requeued b"\xc3"; assert the lead is back in the deque and a later b"\xa9" completes é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I turned this boundary into an executable control-flow reference against the 41be368c ownership shape. The current-shape repro drains a carried b"\xc3", cancels while the next window is waiting, and deterministically leaves the deque empty; the fixed reference restores the drained local buffer synchronously with appendleft(bytes(output)), then a later b"\xa9" reconstructs é. The focused reference suite also covers the Modal transfer below: 6/6 PASS (3 tests prove current bad behavior, 3 prove the proposed ownership repairs).

The narrow shared-collector patch contract is to put the existing collection + final settlement/drain/carry phase under try/except asyncio.CancelledError; if local output is non-empty, synchronously return it to the front of output_chunks before re-raising. The handler needs to cover cancellation from poll_output, waits, settle_output, and the final drain/carry lock acquisition. I have not run the upstream suite for this patch and am not opening a competing PR.

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Modal has the same survivor invariant one layer earlier: a successful stream read is consumed from the provider before poll_output() commits it to entry.output_chunks. Example on this branch: a previous window has carried b"\xc3" in entry.output_chunks; this call reads the continuation b"\xa9" from stdout, then is cancelled during this stderr await. The continuation lives only in local chunks, so it is lost while the older lead byte remains durable. A later call can then pair that lead with unrelated output or eventually replace it.

The exit drain has the same shape internally: _drain_modal_stream() accumulates consumed stream items in its own local bytearray across further awaits before returning them to poll_output(). Catching cancellation only in the outer shared collector therefore cannot recover bytes already removed from Modal's stream.

Please make each Modal ownership transfer cancellation-safe: either commit every successfully consumed stream chunk to entry-owned state before the next await, or catch CancelledError in both poll_output() and _drain_modal_stream() and synchronously restore their local consumed bytes to entry.output_chunks before propagating cancellation. Avoid an awaited lock acquisition as the only copy's next step unless cancellation around that acquisition also restores it. A regression should consume the UTF-8 continuation from stdout, block the following stderr read, cancel there, and verify the continuation remains entry-owned and the next collection reconstructs é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I also exercised this Modal ownership transfer independently. Current-shape repro: stdout consumes the continuation b"\xa9", stderr blocks, caller cancellation arrives, and the continuation never reaches entry.output_chunks; a separate exit-drain repro consumes b"tail", cancels on the next read, and loses that local buffer too. Both are deterministic.

The fixed reference passes by (1) moving each successful stdout/stderr read into local chunks immediately before the next await, (2) catching CancelledError in poll_output and synchronously returning any locally owned chunks to entry.output_chunks, clearing local ownership immediately after a normal queue commit to avoid replay, and (3) giving _drain_modal_stream the same cancellation-return rule for its bytearray. Together with the shared collector repair, the focused reference suite is 6/6 PASS. No provider integration execution or upstream patch application is claimed.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Cross-link for collision/maintainer coordination: #4745 was opened later against the same PTY cross-window UTF-8 / settlement area and has since accumulated a different implementation plus several cancellation/finalization fixes. I’m reviewing both rather than opening a third implementation. The two current cancellation findings I left on this PR (3885258586, 3885258589) are specific to this branch’s carry/Modal ownership boundaries; #4745 currently avoids them with different transaction state, but has had its own finalization lifecycle issues. Recommend treating #4738 and #4745 as competing solutions and selecting one architecture rather than merging them independently.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Selective salvage after comparing #4745 against current 41be368c: I do not think its whole patch should be ported here. Two of the three potentially useful pieces are already covered or architecture-specific: restricted E0/ED/F0/F4 prefix handling is already tested here, and #4745's source_text + final tail truncation repair addresses a post-collection tail path this branch does not use.

One test idea is still useful here: a controlled Unix lifecycle regression where process.returncode is already visible but the output pump still owns the continuation of a split UTF-8 character. Hold the pump before it appends the continuation, assert the first collection/finalization keeps the session live and retains the lead byte, release the pump, then assert the next update returns é and closes. The current implementation looks correct by inspection because _watch_process_exit sets output_closed only after gather(*pump_tasks), but tests/sandbox/test_unix_local.py does not currently pin that ordering.

I would add that regression only after/alongside the two cancellation ownership fixes already posted in 3885258586 and 3885258589. No third competing PR from me.

@HuzaifaChaudary

Copy link
Copy Markdown

hi @seratch. i had a pull request in this area, #4745, which i have just closed in favour of this one. it predates mine and covers the same ground, so there is nothing to weigh up there.

@fscfede-beep reviewed both and suggested one thing from mine might still be worth having, so i am leaving it here as an idea rather than a patch. take it or ignore it.

this pr has the producer drained output_closed design already. the test below pins the ordering it protects, a process reaped while a pump still owns the continuation, which is the case where finalising on the exit code alone silently turns é into :

entry=_UnixPtyProcessEntry(process=process, tty=True) # returncode 0# output_closed deliberately NOT set, the pump still holds the continuationentry.output_chunks.append("é".encode()[:1])
# first update must leave the session alive with the lead byte still queuedassertfirst.process_id==1assertfirst.exit_codeisNoneassertlist(entry.output_chunks) == ["é".encode()[:1]]
# then the pump delivers and closesentry.output_chunks.append("é".encode()[1:])
entry.output_closed.set()
assertfinal.output.decode("utf-8") =="é"assertfinal.process_idisNone

what made me think it earns its place is that it fails for the right reason. swapping the predicate back to entry.process.returncode on its own gives:

PtyExecUpdate(process_id=None, output=b'\xef\xbf\xbd', exit_code=0, original_token_count=None)

so it catches a future tidy up that undoes the predicate, which is a one line change that reads like cleanup. the full version is in the closed branch at HuzaifaChaudary/openai-agents-python@78b5c4d, tests/sandbox/test_unix_local.py::TestUnixLocalPty::test_session_is_not_finalized_while_a_pump_still_holds_output.

happy to open it as a small test only pull request against this branch if you want it, or to leave it entirely. no need to reply if not.

@HuzaifaChaudary

Copy link
Copy Markdown

@seratch, short follow up to my note above and then i will stop.

the two cancellation findings @fscfede-beep raised on this pr are the same two i hit and fixed on the branch i closed, so there is working tested code for both if it saves you deriving them again. commits are on HuzaifaChaudary/openai-agents-python, against my own shape rather than yours, so they are a reference not a patch.

shared collector, 2070572e. exactly the contract he describes:

exceptasyncio.CancelledError:
ifoutput:
output_chunks.appendleft(bytes(output))
raise

modal, bf682c80. the whole buffer goes to entry owned state, carried tail and new reads together, with the try covering both stream reads, the exit poll, the sleep, the drains and the final poll. assigning rather than appending is what stops the carried tail replaying, since chunks already starts with it.

each has a regression that fails on the commit before it. the modal one consumes the continuation off the stream, cancels on the next await, and asserts both halves are still entry owned.

one thing worth flagging, because it bit me right after i fixed these. once the tail flush moved in front of the registry pop, cancelling during the drain skipped _terminate_pty_entry entirely, and since the entry was already out of the map nothing could clean it up later. on blaxel that leaves a websocket and an aiohttp.ClientSession open. 78b5c4d0 puts the drain in a try with the terminate in the finally. worth checking whichever order this branch lands on.

that plus the blocked pump test above is everything i have. no reply needed, and i am not opening anything here.

Co-authored-by: Henry Su <henrysu4707@gmail.com>
Co-authored-by: ayaangazali <ayaangazali.work@gmail.com>
@seratch
seratchforce-pushed the fix/settle-pty-output branch from 41be368 to 2b2d175CompareAugust 31, 2026 02:39

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2b2d1750cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound the second Modal read by the yield deadline

When Modal exposes the supported fallback read interface and both stdout and stderr reads take their 0.2-second timeout, a requested 250 ms yield still takes roughly 400 ms because the stderr read starts unconditionally after stdout has already consumed most or all of the deadline. The fresh evidence in this revision is that the new outer deadline guard skips later polls but does not recheck the deadline between these two sequential reads; check the remaining time before starting stderr or bound each read by the remaining yield window.

Useful? React with 👍 / 👎.

# the close signal that authorizes collector settlement and PTY removal.
async with entry.output_lock:
pass
entry.output_closed.set()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not close E2B output after an indeterminate wait failure

When handle.wait() fails with a transport or runtime exception that has no exit_code, the process may still be running, but this unconditional close signal makes every subsequent output collection return immediately and makes capacity pruning classify the entry as safely settled. That prevents normal long-poll behavior and can cause a still-running process to be preferentially pruned; publish output_closed only after a successful wait or a recognized terminal exception, or retain and surface the indeterminate waiter failure separately.

Useful? React with 👍 / 👎.

@ErenAta16ErenAta16 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.

Flagging the neighbourhood rather than the diff, since three open PRs are editing
these files at once and only one of the three overlaps is a real duplicate.

@Hughhhhcoder's #4750 and @mikemikimike's #4751 both landed the day after this one.
Reading the source changes, the three are addressing different defects:

#4738 threads output_closed through _collect_pty_output output settled before cleanup
#4750 except Exception -> BaseException in pty startup fds leak on cancellation
#4751 wraps _terminate_pty_entry in a cancellation shield teardown aborts on cancellation

So they are complementary, and none of them subsumes another. All three touch
sandbox/sandboxes/unix_local.py, at lines 354, 391/463 and 400/442 respectively,
which is close enough to be worth knowing about but far enough apart that the
hunks should not fight.

The real duplicate is the helper, not the call sites.#4750 adds
_settle_pty_cleanup as a module function in sandbox/session/pty_types.py, and
#4751 adds _settle_pty_cleanup as a method on BaseSandboxSession in
sandbox/session/base_sandbox_session.py. Same name, same shield-in-a-loop
algorithm, same completion.result() then task.result() sequence. Merging both
leaves the SDK with two of them.

They are not equivalent, and the difference shows up when cleanup itself fails
while the caller is being cancelled:

cleanup raises, caller cancelled
#4750 -> CancelledError propagates task.cancelled() = True
#4751 -> RuntimeError propagates task.cancelled() = False

#4750 gives the caller's cancellation priority over the cleanup error; #4751
reaches task.result() before its caller_cancellation check, so the cleanup
exception wins and the CancelledError is dropped. A task that was asked to stop
and then reports cancelled() is False is the case wait_for and TaskGroup
both rely on, so I would take #4750's ordering whichever module the helper ends
up living in.

One completeness note that may save a round trip on #4750: the
except Exception -> except BaseException change there is the only site of its
shape. I walked the AST of everything under sandbox/ and
extensions/sandbox/, looking for a try whose body contains an await and
whose sole handler is except Exception while closing a descriptor, and
unix_local.py:357 is the single match. entries/artifacts.py:714 looks similar
but its try body is fully synchronous, so Exception is sufficient there and it
is not an outlier.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@seratch@fscfede-beep@HuzaifaChaudary@ErenAta16
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(sandbox): settle PTY output before cleanup by seratch · Pull Request #4738 · openai/openai-agents-python · GitHub
Skip to content

fix(sandbox): settle PTY output before cleanup - #4738

Open
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output
Open

fix(sandbox): settle PTY output before cleanup#4738
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output

Conversation

@seratch

Copy link
Copy Markdown
Member

This pull request fixes PTY output settlement and supersedes #4572 and #4724. PTY collectors now re-drain output at timeout boundaries, carry only complete valid UTF-8 sequences across read windows, and make bounded replacement progress for invalid E0, ED, F0, and F4 prefixes.

Terminal cleanup now follows a collector-owned settled output_closed fact across local, Docker, E2B, Cloudflare, Modal, Blaxel, and Daytona adapters, so exit visibility cannot drop queued bytes or carried suffixes.

@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Aug 28, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-08-31T02:44:42.234416Z2b2d175New commits
🔒 Security ReviewCompleted2026-08-31T02:44:01.462995Z2b2d175New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:21c32b9985

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadsrc/agents/sandbox/session/pty_output.py Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch
seratchforce-pushed the fix/settle-pty-output branch from 21c32b9 to 41be368CompareAugust 28, 2026 10:12

@fscfede-beepfscfede-beep left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I re-audited the current 41be368c head specifically around cancellation ownership. Two cancellation boundaries can still lose bytes that this PR now intends to carry across PTY windows.

await wait_for_output(remaining_s)
else:
try:
await asyncio.wait_for(output_notify.wait(), timeout=remaining_s)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancellation can drop a UTF-8 prefix that a previous window deliberately carried for this surviving session. Deterministic ordering on current head: first window ends with b"\xc3", so _drain_and_carry_incomplete_suffix() leaves [b"\xc3"] on output_chunks; the next collection drains that lead byte into local output, reaches this output_notify wait, and is cancelled. There is no outer cancellation handler, so the deque is now empty. If b"\xa9" arrives later, the next/final collection sees the continuation alone and returns instead of é.

This is in-scope for this PR: before the new cross-window carry, a cancelled call could abandon its own drained window, but it could not consume persistent UTF-8 state intentionally handed forward by an earlier successful call and thereby corrupt later output.

The narrow fix is to make the drained local buffer transactional. Wrap collection/settlement in try/except asyncio.CancelledError; if output is non-empty, restore it synchronously to the front of output_chunks before re-raising (or keep ownership on the entry until commit). appendleft(bytes(output)) preserves these older bytes ahead of anything a producer queued while the call was running. Regression: cancel the second window while it is waiting here after draining the requeued b"\xc3"; assert the lead is back in the deque and a later b"\xa9" completes é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I turned this boundary into an executable control-flow reference against the 41be368c ownership shape. The current-shape repro drains a carried b"\xc3", cancels while the next window is waiting, and deterministically leaves the deque empty; the fixed reference restores the drained local buffer synchronously with appendleft(bytes(output)), then a later b"\xa9" reconstructs é. The focused reference suite also covers the Modal transfer below: 6/6 PASS (3 tests prove current bad behavior, 3 prove the proposed ownership repairs).

The narrow shared-collector patch contract is to put the existing collection + final settlement/drain/carry phase under try/except asyncio.CancelledError; if local output is non-empty, synchronously return it to the front of output_chunks before re-raising. The handler needs to cover cancellation from poll_output, waits, settle_output, and the final drain/carry lock acquisition. I have not run the upstream suite for this patch and am not opening a competing PR.

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Modal has the same survivor invariant one layer earlier: a successful stream read is consumed from the provider before poll_output() commits it to entry.output_chunks. Example on this branch: a previous window has carried b"\xc3" in entry.output_chunks; this call reads the continuation b"\xa9" from stdout, then is cancelled during this stderr await. The continuation lives only in local chunks, so it is lost while the older lead byte remains durable. A later call can then pair that lead with unrelated output or eventually replace it.

The exit drain has the same shape internally: _drain_modal_stream() accumulates consumed stream items in its own local bytearray across further awaits before returning them to poll_output(). Catching cancellation only in the outer shared collector therefore cannot recover bytes already removed from Modal's stream.

Please make each Modal ownership transfer cancellation-safe: either commit every successfully consumed stream chunk to entry-owned state before the next await, or catch CancelledError in both poll_output() and _drain_modal_stream() and synchronously restore their local consumed bytes to entry.output_chunks before propagating cancellation. Avoid an awaited lock acquisition as the only copy's next step unless cancellation around that acquisition also restores it. A regression should consume the UTF-8 continuation from stdout, block the following stderr read, cancel there, and verify the continuation remains entry-owned and the next collection reconstructs é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I also exercised this Modal ownership transfer independently. Current-shape repro: stdout consumes the continuation b"\xa9", stderr blocks, caller cancellation arrives, and the continuation never reaches entry.output_chunks; a separate exit-drain repro consumes b"tail", cancels on the next read, and loses that local buffer too. Both are deterministic.

The fixed reference passes by (1) moving each successful stdout/stderr read into local chunks immediately before the next await, (2) catching CancelledError in poll_output and synchronously returning any locally owned chunks to entry.output_chunks, clearing local ownership immediately after a normal queue commit to avoid replay, and (3) giving _drain_modal_stream the same cancellation-return rule for its bytearray. Together with the shared collector repair, the focused reference suite is 6/6 PASS. No provider integration execution or upstream patch application is claimed.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Cross-link for collision/maintainer coordination: #4745 was opened later against the same PTY cross-window UTF-8 / settlement area and has since accumulated a different implementation plus several cancellation/finalization fixes. I’m reviewing both rather than opening a third implementation. The two current cancellation findings I left on this PR (3885258586, 3885258589) are specific to this branch’s carry/Modal ownership boundaries; #4745 currently avoids them with different transaction state, but has had its own finalization lifecycle issues. Recommend treating #4738 and #4745 as competing solutions and selecting one architecture rather than merging them independently.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Selective salvage after comparing #4745 against current 41be368c: I do not think its whole patch should be ported here. Two of the three potentially useful pieces are already covered or architecture-specific: restricted E0/ED/F0/F4 prefix handling is already tested here, and #4745's source_text + final tail truncation repair addresses a post-collection tail path this branch does not use.

One test idea is still useful here: a controlled Unix lifecycle regression where process.returncode is already visible but the output pump still owns the continuation of a split UTF-8 character. Hold the pump before it appends the continuation, assert the first collection/finalization keeps the session live and retains the lead byte, release the pump, then assert the next update returns é and closes. The current implementation looks correct by inspection because _watch_process_exit sets output_closed only after gather(*pump_tasks), but tests/sandbox/test_unix_local.py does not currently pin that ordering.

I would add that regression only after/alongside the two cancellation ownership fixes already posted in 3885258586 and 3885258589. No third competing PR from me.

@HuzaifaChaudary

Copy link
Copy Markdown

hi @seratch. i had a pull request in this area, #4745, which i have just closed in favour of this one. it predates mine and covers the same ground, so there is nothing to weigh up there.

@fscfede-beep reviewed both and suggested one thing from mine might still be worth having, so i am leaving it here as an idea rather than a patch. take it or ignore it.

this pr has the producer drained output_closed design already. the test below pins the ordering it protects, a process reaped while a pump still owns the continuation, which is the case where finalising on the exit code alone silently turns é into :

entry=_UnixPtyProcessEntry(process=process, tty=True) # returncode 0# output_closed deliberately NOT set, the pump still holds the continuationentry.output_chunks.append("é".encode()[:1])
# first update must leave the session alive with the lead byte still queuedassertfirst.process_id==1assertfirst.exit_codeisNoneassertlist(entry.output_chunks) == ["é".encode()[:1]]
# then the pump delivers and closesentry.output_chunks.append("é".encode()[1:])
entry.output_closed.set()
assertfinal.output.decode("utf-8") =="é"assertfinal.process_idisNone

what made me think it earns its place is that it fails for the right reason. swapping the predicate back to entry.process.returncode on its own gives:

PtyExecUpdate(process_id=None, output=b'\xef\xbf\xbd', exit_code=0, original_token_count=None)

so it catches a future tidy up that undoes the predicate, which is a one line change that reads like cleanup. the full version is in the closed branch at HuzaifaChaudary/openai-agents-python@78b5c4d, tests/sandbox/test_unix_local.py::TestUnixLocalPty::test_session_is_not_finalized_while_a_pump_still_holds_output.

happy to open it as a small test only pull request against this branch if you want it, or to leave it entirely. no need to reply if not.

@HuzaifaChaudary

Copy link
Copy Markdown

@seratch, short follow up to my note above and then i will stop.

the two cancellation findings @fscfede-beep raised on this pr are the same two i hit and fixed on the branch i closed, so there is working tested code for both if it saves you deriving them again. commits are on HuzaifaChaudary/openai-agents-python, against my own shape rather than yours, so they are a reference not a patch.

shared collector, 2070572e. exactly the contract he describes:

exceptasyncio.CancelledError:
ifoutput:
output_chunks.appendleft(bytes(output))
raise

modal, bf682c80. the whole buffer goes to entry owned state, carried tail and new reads together, with the try covering both stream reads, the exit poll, the sleep, the drains and the final poll. assigning rather than appending is what stops the carried tail replaying, since chunks already starts with it.

each has a regression that fails on the commit before it. the modal one consumes the continuation off the stream, cancels on the next await, and asserts both halves are still entry owned.

one thing worth flagging, because it bit me right after i fixed these. once the tail flush moved in front of the registry pop, cancelling during the drain skipped _terminate_pty_entry entirely, and since the entry was already out of the map nothing could clean it up later. on blaxel that leaves a websocket and an aiohttp.ClientSession open. 78b5c4d0 puts the drain in a try with the terminate in the finally. worth checking whichever order this branch lands on.

that plus the blocked pump test above is everything i have. no reply needed, and i am not opening anything here.

Co-authored-by: Henry Su <henrysu4707@gmail.com>
Co-authored-by: ayaangazali <ayaangazali.work@gmail.com>
@seratch
seratchforce-pushed the fix/settle-pty-output branch from 41be368 to 2b2d175CompareAugust 31, 2026 02:39

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2b2d1750cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound the second Modal read by the yield deadline

When Modal exposes the supported fallback read interface and both stdout and stderr reads take their 0.2-second timeout, a requested 250 ms yield still takes roughly 400 ms because the stderr read starts unconditionally after stdout has already consumed most or all of the deadline. The fresh evidence in this revision is that the new outer deadline guard skips later polls but does not recheck the deadline between these two sequential reads; check the remaining time before starting stderr or bound each read by the remaining yield window.

Useful? React with 👍 / 👎.

# the close signal that authorizes collector settlement and PTY removal.
async with entry.output_lock:
pass
entry.output_closed.set()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not close E2B output after an indeterminate wait failure

When handle.wait() fails with a transport or runtime exception that has no exit_code, the process may still be running, but this unconditional close signal makes every subsequent output collection return immediately and makes capacity pruning classify the entry as safely settled. That prevents normal long-poll behavior and can cause a still-running process to be preferentially pruned; publish output_closed only after a successful wait or a recognized terminal exception, or retain and surface the indeterminate waiter failure separately.

Useful? React with 👍 / 👎.

@ErenAta16ErenAta16 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.

Flagging the neighbourhood rather than the diff, since three open PRs are editing
these files at once and only one of the three overlaps is a real duplicate.

@Hughhhhcoder's #4750 and @mikemikimike's #4751 both landed the day after this one.
Reading the source changes, the three are addressing different defects:

#4738 threads output_closed through _collect_pty_output output settled before cleanup
#4750 except Exception -> BaseException in pty startup fds leak on cancellation
#4751 wraps _terminate_pty_entry in a cancellation shield teardown aborts on cancellation

So they are complementary, and none of them subsumes another. All three touch
sandbox/sandboxes/unix_local.py, at lines 354, 391/463 and 400/442 respectively,
which is close enough to be worth knowing about but far enough apart that the
hunks should not fight.

The real duplicate is the helper, not the call sites.#4750 adds
_settle_pty_cleanup as a module function in sandbox/session/pty_types.py, and
#4751 adds _settle_pty_cleanup as a method on BaseSandboxSession in
sandbox/session/base_sandbox_session.py. Same name, same shield-in-a-loop
algorithm, same completion.result() then task.result() sequence. Merging both
leaves the SDK with two of them.

They are not equivalent, and the difference shows up when cleanup itself fails
while the caller is being cancelled:

cleanup raises, caller cancelled
#4750 -> CancelledError propagates task.cancelled() = True
#4751 -> RuntimeError propagates task.cancelled() = False

#4750 gives the caller's cancellation priority over the cleanup error; #4751
reaches task.result() before its caller_cancellation check, so the cleanup
exception wins and the CancelledError is dropped. A task that was asked to stop
and then reports cancelled() is False is the case wait_for and TaskGroup
both rely on, so I would take #4750's ordering whichever module the helper ends
up living in.

One completeness note that may save a round trip on #4750: the
except Exception -> except BaseException change there is the only site of its
shape. I walked the AST of everything under sandbox/ and
extensions/sandbox/, looking for a try whose body contains an await and
whose sole handler is except Exception while closing a descriptor, and
unix_local.py:357 is the single match. entries/artifacts.py:714 looks similar
but its try body is fully synchronous, so Exception is sufficient there and it
is not an outlier.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@seratch@fscfede-beep@HuzaifaChaudary@ErenAta16
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(sandbox): settle PTY output before cleanup by seratch · Pull Request #4738 · openai/openai-agents-python · GitHub
Skip to content

fix(sandbox): settle PTY output before cleanup - #4738

Open
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output
Open

fix(sandbox): settle PTY output before cleanup#4738
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output

Conversation

@seratch

Copy link
Copy Markdown
Member

This pull request fixes PTY output settlement and supersedes #4572 and #4724. PTY collectors now re-drain output at timeout boundaries, carry only complete valid UTF-8 sequences across read windows, and make bounded replacement progress for invalid E0, ED, F0, and F4 prefixes.

Terminal cleanup now follows a collector-owned settled output_closed fact across local, Docker, E2B, Cloudflare, Modal, Blaxel, and Daytona adapters, so exit visibility cannot drop queued bytes or carried suffixes.

@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Aug 28, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-08-31T02:44:42.234416Z2b2d175New commits
🔒 Security ReviewCompleted2026-08-31T02:44:01.462995Z2b2d175New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:21c32b9985

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadsrc/agents/sandbox/session/pty_output.py Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch
seratchforce-pushed the fix/settle-pty-output branch from 21c32b9 to 41be368CompareAugust 28, 2026 10:12

@fscfede-beepfscfede-beep left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I re-audited the current 41be368c head specifically around cancellation ownership. Two cancellation boundaries can still lose bytes that this PR now intends to carry across PTY windows.

await wait_for_output(remaining_s)
else:
try:
await asyncio.wait_for(output_notify.wait(), timeout=remaining_s)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancellation can drop a UTF-8 prefix that a previous window deliberately carried for this surviving session. Deterministic ordering on current head: first window ends with b"\xc3", so _drain_and_carry_incomplete_suffix() leaves [b"\xc3"] on output_chunks; the next collection drains that lead byte into local output, reaches this output_notify wait, and is cancelled. There is no outer cancellation handler, so the deque is now empty. If b"\xa9" arrives later, the next/final collection sees the continuation alone and returns instead of é.

This is in-scope for this PR: before the new cross-window carry, a cancelled call could abandon its own drained window, but it could not consume persistent UTF-8 state intentionally handed forward by an earlier successful call and thereby corrupt later output.

The narrow fix is to make the drained local buffer transactional. Wrap collection/settlement in try/except asyncio.CancelledError; if output is non-empty, restore it synchronously to the front of output_chunks before re-raising (or keep ownership on the entry until commit). appendleft(bytes(output)) preserves these older bytes ahead of anything a producer queued while the call was running. Regression: cancel the second window while it is waiting here after draining the requeued b"\xc3"; assert the lead is back in the deque and a later b"\xa9" completes é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I turned this boundary into an executable control-flow reference against the 41be368c ownership shape. The current-shape repro drains a carried b"\xc3", cancels while the next window is waiting, and deterministically leaves the deque empty; the fixed reference restores the drained local buffer synchronously with appendleft(bytes(output)), then a later b"\xa9" reconstructs é. The focused reference suite also covers the Modal transfer below: 6/6 PASS (3 tests prove current bad behavior, 3 prove the proposed ownership repairs).

The narrow shared-collector patch contract is to put the existing collection + final settlement/drain/carry phase under try/except asyncio.CancelledError; if local output is non-empty, synchronously return it to the front of output_chunks before re-raising. The handler needs to cover cancellation from poll_output, waits, settle_output, and the final drain/carry lock acquisition. I have not run the upstream suite for this patch and am not opening a competing PR.

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Modal has the same survivor invariant one layer earlier: a successful stream read is consumed from the provider before poll_output() commits it to entry.output_chunks. Example on this branch: a previous window has carried b"\xc3" in entry.output_chunks; this call reads the continuation b"\xa9" from stdout, then is cancelled during this stderr await. The continuation lives only in local chunks, so it is lost while the older lead byte remains durable. A later call can then pair that lead with unrelated output or eventually replace it.

The exit drain has the same shape internally: _drain_modal_stream() accumulates consumed stream items in its own local bytearray across further awaits before returning them to poll_output(). Catching cancellation only in the outer shared collector therefore cannot recover bytes already removed from Modal's stream.

Please make each Modal ownership transfer cancellation-safe: either commit every successfully consumed stream chunk to entry-owned state before the next await, or catch CancelledError in both poll_output() and _drain_modal_stream() and synchronously restore their local consumed bytes to entry.output_chunks before propagating cancellation. Avoid an awaited lock acquisition as the only copy's next step unless cancellation around that acquisition also restores it. A regression should consume the UTF-8 continuation from stdout, block the following stderr read, cancel there, and verify the continuation remains entry-owned and the next collection reconstructs é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I also exercised this Modal ownership transfer independently. Current-shape repro: stdout consumes the continuation b"\xa9", stderr blocks, caller cancellation arrives, and the continuation never reaches entry.output_chunks; a separate exit-drain repro consumes b"tail", cancels on the next read, and loses that local buffer too. Both are deterministic.

The fixed reference passes by (1) moving each successful stdout/stderr read into local chunks immediately before the next await, (2) catching CancelledError in poll_output and synchronously returning any locally owned chunks to entry.output_chunks, clearing local ownership immediately after a normal queue commit to avoid replay, and (3) giving _drain_modal_stream the same cancellation-return rule for its bytearray. Together with the shared collector repair, the focused reference suite is 6/6 PASS. No provider integration execution or upstream patch application is claimed.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Cross-link for collision/maintainer coordination: #4745 was opened later against the same PTY cross-window UTF-8 / settlement area and has since accumulated a different implementation plus several cancellation/finalization fixes. I’m reviewing both rather than opening a third implementation. The two current cancellation findings I left on this PR (3885258586, 3885258589) are specific to this branch’s carry/Modal ownership boundaries; #4745 currently avoids them with different transaction state, but has had its own finalization lifecycle issues. Recommend treating #4738 and #4745 as competing solutions and selecting one architecture rather than merging them independently.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Selective salvage after comparing #4745 against current 41be368c: I do not think its whole patch should be ported here. Two of the three potentially useful pieces are already covered or architecture-specific: restricted E0/ED/F0/F4 prefix handling is already tested here, and #4745's source_text + final tail truncation repair addresses a post-collection tail path this branch does not use.

One test idea is still useful here: a controlled Unix lifecycle regression where process.returncode is already visible but the output pump still owns the continuation of a split UTF-8 character. Hold the pump before it appends the continuation, assert the first collection/finalization keeps the session live and retains the lead byte, release the pump, then assert the next update returns é and closes. The current implementation looks correct by inspection because _watch_process_exit sets output_closed only after gather(*pump_tasks), but tests/sandbox/test_unix_local.py does not currently pin that ordering.

I would add that regression only after/alongside the two cancellation ownership fixes already posted in 3885258586 and 3885258589. No third competing PR from me.

@HuzaifaChaudary

Copy link
Copy Markdown

hi @seratch. i had a pull request in this area, #4745, which i have just closed in favour of this one. it predates mine and covers the same ground, so there is nothing to weigh up there.

@fscfede-beep reviewed both and suggested one thing from mine might still be worth having, so i am leaving it here as an idea rather than a patch. take it or ignore it.

this pr has the producer drained output_closed design already. the test below pins the ordering it protects, a process reaped while a pump still owns the continuation, which is the case where finalising on the exit code alone silently turns é into :

entry=_UnixPtyProcessEntry(process=process, tty=True) # returncode 0# output_closed deliberately NOT set, the pump still holds the continuationentry.output_chunks.append("é".encode()[:1])
# first update must leave the session alive with the lead byte still queuedassertfirst.process_id==1assertfirst.exit_codeisNoneassertlist(entry.output_chunks) == ["é".encode()[:1]]
# then the pump delivers and closesentry.output_chunks.append("é".encode()[1:])
entry.output_closed.set()
assertfinal.output.decode("utf-8") =="é"assertfinal.process_idisNone

what made me think it earns its place is that it fails for the right reason. swapping the predicate back to entry.process.returncode on its own gives:

PtyExecUpdate(process_id=None, output=b'\xef\xbf\xbd', exit_code=0, original_token_count=None)

so it catches a future tidy up that undoes the predicate, which is a one line change that reads like cleanup. the full version is in the closed branch at HuzaifaChaudary/openai-agents-python@78b5c4d, tests/sandbox/test_unix_local.py::TestUnixLocalPty::test_session_is_not_finalized_while_a_pump_still_holds_output.

happy to open it as a small test only pull request against this branch if you want it, or to leave it entirely. no need to reply if not.

@HuzaifaChaudary

Copy link
Copy Markdown

@seratch, short follow up to my note above and then i will stop.

the two cancellation findings @fscfede-beep raised on this pr are the same two i hit and fixed on the branch i closed, so there is working tested code for both if it saves you deriving them again. commits are on HuzaifaChaudary/openai-agents-python, against my own shape rather than yours, so they are a reference not a patch.

shared collector, 2070572e. exactly the contract he describes:

exceptasyncio.CancelledError:
ifoutput:
output_chunks.appendleft(bytes(output))
raise

modal, bf682c80. the whole buffer goes to entry owned state, carried tail and new reads together, with the try covering both stream reads, the exit poll, the sleep, the drains and the final poll. assigning rather than appending is what stops the carried tail replaying, since chunks already starts with it.

each has a regression that fails on the commit before it. the modal one consumes the continuation off the stream, cancels on the next await, and asserts both halves are still entry owned.

one thing worth flagging, because it bit me right after i fixed these. once the tail flush moved in front of the registry pop, cancelling during the drain skipped _terminate_pty_entry entirely, and since the entry was already out of the map nothing could clean it up later. on blaxel that leaves a websocket and an aiohttp.ClientSession open. 78b5c4d0 puts the drain in a try with the terminate in the finally. worth checking whichever order this branch lands on.

that plus the blocked pump test above is everything i have. no reply needed, and i am not opening anything here.

Co-authored-by: Henry Su <henrysu4707@gmail.com>
Co-authored-by: ayaangazali <ayaangazali.work@gmail.com>
@seratch
seratchforce-pushed the fix/settle-pty-output branch from 41be368 to 2b2d175CompareAugust 31, 2026 02:39

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2b2d1750cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound the second Modal read by the yield deadline

When Modal exposes the supported fallback read interface and both stdout and stderr reads take their 0.2-second timeout, a requested 250 ms yield still takes roughly 400 ms because the stderr read starts unconditionally after stdout has already consumed most or all of the deadline. The fresh evidence in this revision is that the new outer deadline guard skips later polls but does not recheck the deadline between these two sequential reads; check the remaining time before starting stderr or bound each read by the remaining yield window.

Useful? React with 👍 / 👎.

# the close signal that authorizes collector settlement and PTY removal.
async with entry.output_lock:
pass
entry.output_closed.set()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not close E2B output after an indeterminate wait failure

When handle.wait() fails with a transport or runtime exception that has no exit_code, the process may still be running, but this unconditional close signal makes every subsequent output collection return immediately and makes capacity pruning classify the entry as safely settled. That prevents normal long-poll behavior and can cause a still-running process to be preferentially pruned; publish output_closed only after a successful wait or a recognized terminal exception, or retain and surface the indeterminate waiter failure separately.

Useful? React with 👍 / 👎.

@ErenAta16ErenAta16 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.

Flagging the neighbourhood rather than the diff, since three open PRs are editing
these files at once and only one of the three overlaps is a real duplicate.

@Hughhhhcoder's #4750 and @mikemikimike's #4751 both landed the day after this one.
Reading the source changes, the three are addressing different defects:

#4738 threads output_closed through _collect_pty_output output settled before cleanup
#4750 except Exception -> BaseException in pty startup fds leak on cancellation
#4751 wraps _terminate_pty_entry in a cancellation shield teardown aborts on cancellation

So they are complementary, and none of them subsumes another. All three touch
sandbox/sandboxes/unix_local.py, at lines 354, 391/463 and 400/442 respectively,
which is close enough to be worth knowing about but far enough apart that the
hunks should not fight.

The real duplicate is the helper, not the call sites.#4750 adds
_settle_pty_cleanup as a module function in sandbox/session/pty_types.py, and
#4751 adds _settle_pty_cleanup as a method on BaseSandboxSession in
sandbox/session/base_sandbox_session.py. Same name, same shield-in-a-loop
algorithm, same completion.result() then task.result() sequence. Merging both
leaves the SDK with two of them.

They are not equivalent, and the difference shows up when cleanup itself fails
while the caller is being cancelled:

cleanup raises, caller cancelled
#4750 -> CancelledError propagates task.cancelled() = True
#4751 -> RuntimeError propagates task.cancelled() = False

#4750 gives the caller's cancellation priority over the cleanup error; #4751
reaches task.result() before its caller_cancellation check, so the cleanup
exception wins and the CancelledError is dropped. A task that was asked to stop
and then reports cancelled() is False is the case wait_for and TaskGroup
both rely on, so I would take #4750's ordering whichever module the helper ends
up living in.

One completeness note that may save a round trip on #4750: the
except Exception -> except BaseException change there is the only site of its
shape. I walked the AST of everything under sandbox/ and
extensions/sandbox/, looking for a try whose body contains an await and
whose sole handler is except Exception while closing a descriptor, and
unix_local.py:357 is the single match. entries/artifacts.py:714 looks similar
but its try body is fully synchronous, so Exception is sufficient there and it
is not an outlier.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@seratch@fscfede-beep@HuzaifaChaudary@ErenAta16
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(sandbox): settle PTY output before cleanup by seratch · Pull Request #4738 · openai/openai-agents-python · GitHub
Skip to content

fix(sandbox): settle PTY output before cleanup - #4738

Open
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output
Open

fix(sandbox): settle PTY output before cleanup#4738
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output

Conversation

@seratch

Copy link
Copy Markdown
Member

This pull request fixes PTY output settlement and supersedes #4572 and #4724. PTY collectors now re-drain output at timeout boundaries, carry only complete valid UTF-8 sequences across read windows, and make bounded replacement progress for invalid E0, ED, F0, and F4 prefixes.

Terminal cleanup now follows a collector-owned settled output_closed fact across local, Docker, E2B, Cloudflare, Modal, Blaxel, and Daytona adapters, so exit visibility cannot drop queued bytes or carried suffixes.

@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Aug 28, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-08-31T02:44:42.234416Z2b2d175New commits
🔒 Security ReviewCompleted2026-08-31T02:44:01.462995Z2b2d175New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:21c32b9985

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadsrc/agents/sandbox/session/pty_output.py Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch
seratchforce-pushed the fix/settle-pty-output branch from 21c32b9 to 41be368CompareAugust 28, 2026 10:12

@fscfede-beepfscfede-beep left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I re-audited the current 41be368c head specifically around cancellation ownership. Two cancellation boundaries can still lose bytes that this PR now intends to carry across PTY windows.

await wait_for_output(remaining_s)
else:
try:
await asyncio.wait_for(output_notify.wait(), timeout=remaining_s)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancellation can drop a UTF-8 prefix that a previous window deliberately carried for this surviving session. Deterministic ordering on current head: first window ends with b"\xc3", so _drain_and_carry_incomplete_suffix() leaves [b"\xc3"] on output_chunks; the next collection drains that lead byte into local output, reaches this output_notify wait, and is cancelled. There is no outer cancellation handler, so the deque is now empty. If b"\xa9" arrives later, the next/final collection sees the continuation alone and returns instead of é.

This is in-scope for this PR: before the new cross-window carry, a cancelled call could abandon its own drained window, but it could not consume persistent UTF-8 state intentionally handed forward by an earlier successful call and thereby corrupt later output.

The narrow fix is to make the drained local buffer transactional. Wrap collection/settlement in try/except asyncio.CancelledError; if output is non-empty, restore it synchronously to the front of output_chunks before re-raising (or keep ownership on the entry until commit). appendleft(bytes(output)) preserves these older bytes ahead of anything a producer queued while the call was running. Regression: cancel the second window while it is waiting here after draining the requeued b"\xc3"; assert the lead is back in the deque and a later b"\xa9" completes é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I turned this boundary into an executable control-flow reference against the 41be368c ownership shape. The current-shape repro drains a carried b"\xc3", cancels while the next window is waiting, and deterministically leaves the deque empty; the fixed reference restores the drained local buffer synchronously with appendleft(bytes(output)), then a later b"\xa9" reconstructs é. The focused reference suite also covers the Modal transfer below: 6/6 PASS (3 tests prove current bad behavior, 3 prove the proposed ownership repairs).

The narrow shared-collector patch contract is to put the existing collection + final settlement/drain/carry phase under try/except asyncio.CancelledError; if local output is non-empty, synchronously return it to the front of output_chunks before re-raising. The handler needs to cover cancellation from poll_output, waits, settle_output, and the final drain/carry lock acquisition. I have not run the upstream suite for this patch and am not opening a competing PR.

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Modal has the same survivor invariant one layer earlier: a successful stream read is consumed from the provider before poll_output() commits it to entry.output_chunks. Example on this branch: a previous window has carried b"\xc3" in entry.output_chunks; this call reads the continuation b"\xa9" from stdout, then is cancelled during this stderr await. The continuation lives only in local chunks, so it is lost while the older lead byte remains durable. A later call can then pair that lead with unrelated output or eventually replace it.

The exit drain has the same shape internally: _drain_modal_stream() accumulates consumed stream items in its own local bytearray across further awaits before returning them to poll_output(). Catching cancellation only in the outer shared collector therefore cannot recover bytes already removed from Modal's stream.

Please make each Modal ownership transfer cancellation-safe: either commit every successfully consumed stream chunk to entry-owned state before the next await, or catch CancelledError in both poll_output() and _drain_modal_stream() and synchronously restore their local consumed bytes to entry.output_chunks before propagating cancellation. Avoid an awaited lock acquisition as the only copy's next step unless cancellation around that acquisition also restores it. A regression should consume the UTF-8 continuation from stdout, block the following stderr read, cancel there, and verify the continuation remains entry-owned and the next collection reconstructs é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I also exercised this Modal ownership transfer independently. Current-shape repro: stdout consumes the continuation b"\xa9", stderr blocks, caller cancellation arrives, and the continuation never reaches entry.output_chunks; a separate exit-drain repro consumes b"tail", cancels on the next read, and loses that local buffer too. Both are deterministic.

The fixed reference passes by (1) moving each successful stdout/stderr read into local chunks immediately before the next await, (2) catching CancelledError in poll_output and synchronously returning any locally owned chunks to entry.output_chunks, clearing local ownership immediately after a normal queue commit to avoid replay, and (3) giving _drain_modal_stream the same cancellation-return rule for its bytearray. Together with the shared collector repair, the focused reference suite is 6/6 PASS. No provider integration execution or upstream patch application is claimed.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Cross-link for collision/maintainer coordination: #4745 was opened later against the same PTY cross-window UTF-8 / settlement area and has since accumulated a different implementation plus several cancellation/finalization fixes. I’m reviewing both rather than opening a third implementation. The two current cancellation findings I left on this PR (3885258586, 3885258589) are specific to this branch’s carry/Modal ownership boundaries; #4745 currently avoids them with different transaction state, but has had its own finalization lifecycle issues. Recommend treating #4738 and #4745 as competing solutions and selecting one architecture rather than merging them independently.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Selective salvage after comparing #4745 against current 41be368c: I do not think its whole patch should be ported here. Two of the three potentially useful pieces are already covered or architecture-specific: restricted E0/ED/F0/F4 prefix handling is already tested here, and #4745's source_text + final tail truncation repair addresses a post-collection tail path this branch does not use.

One test idea is still useful here: a controlled Unix lifecycle regression where process.returncode is already visible but the output pump still owns the continuation of a split UTF-8 character. Hold the pump before it appends the continuation, assert the first collection/finalization keeps the session live and retains the lead byte, release the pump, then assert the next update returns é and closes. The current implementation looks correct by inspection because _watch_process_exit sets output_closed only after gather(*pump_tasks), but tests/sandbox/test_unix_local.py does not currently pin that ordering.

I would add that regression only after/alongside the two cancellation ownership fixes already posted in 3885258586 and 3885258589. No third competing PR from me.

@HuzaifaChaudary

Copy link
Copy Markdown

hi @seratch. i had a pull request in this area, #4745, which i have just closed in favour of this one. it predates mine and covers the same ground, so there is nothing to weigh up there.

@fscfede-beep reviewed both and suggested one thing from mine might still be worth having, so i am leaving it here as an idea rather than a patch. take it or ignore it.

this pr has the producer drained output_closed design already. the test below pins the ordering it protects, a process reaped while a pump still owns the continuation, which is the case where finalising on the exit code alone silently turns é into :

entry=_UnixPtyProcessEntry(process=process, tty=True) # returncode 0# output_closed deliberately NOT set, the pump still holds the continuationentry.output_chunks.append("é".encode()[:1])
# first update must leave the session alive with the lead byte still queuedassertfirst.process_id==1assertfirst.exit_codeisNoneassertlist(entry.output_chunks) == ["é".encode()[:1]]
# then the pump delivers and closesentry.output_chunks.append("é".encode()[1:])
entry.output_closed.set()
assertfinal.output.decode("utf-8") =="é"assertfinal.process_idisNone

what made me think it earns its place is that it fails for the right reason. swapping the predicate back to entry.process.returncode on its own gives:

PtyExecUpdate(process_id=None, output=b'\xef\xbf\xbd', exit_code=0, original_token_count=None)

so it catches a future tidy up that undoes the predicate, which is a one line change that reads like cleanup. the full version is in the closed branch at HuzaifaChaudary/openai-agents-python@78b5c4d, tests/sandbox/test_unix_local.py::TestUnixLocalPty::test_session_is_not_finalized_while_a_pump_still_holds_output.

happy to open it as a small test only pull request against this branch if you want it, or to leave it entirely. no need to reply if not.

@HuzaifaChaudary

Copy link
Copy Markdown

@seratch, short follow up to my note above and then i will stop.

the two cancellation findings @fscfede-beep raised on this pr are the same two i hit and fixed on the branch i closed, so there is working tested code for both if it saves you deriving them again. commits are on HuzaifaChaudary/openai-agents-python, against my own shape rather than yours, so they are a reference not a patch.

shared collector, 2070572e. exactly the contract he describes:

exceptasyncio.CancelledError:
ifoutput:
output_chunks.appendleft(bytes(output))
raise

modal, bf682c80. the whole buffer goes to entry owned state, carried tail and new reads together, with the try covering both stream reads, the exit poll, the sleep, the drains and the final poll. assigning rather than appending is what stops the carried tail replaying, since chunks already starts with it.

each has a regression that fails on the commit before it. the modal one consumes the continuation off the stream, cancels on the next await, and asserts both halves are still entry owned.

one thing worth flagging, because it bit me right after i fixed these. once the tail flush moved in front of the registry pop, cancelling during the drain skipped _terminate_pty_entry entirely, and since the entry was already out of the map nothing could clean it up later. on blaxel that leaves a websocket and an aiohttp.ClientSession open. 78b5c4d0 puts the drain in a try with the terminate in the finally. worth checking whichever order this branch lands on.

that plus the blocked pump test above is everything i have. no reply needed, and i am not opening anything here.

Co-authored-by: Henry Su <henrysu4707@gmail.com>
Co-authored-by: ayaangazali <ayaangazali.work@gmail.com>
@seratch
seratchforce-pushed the fix/settle-pty-output branch from 41be368 to 2b2d175CompareAugust 31, 2026 02:39

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2b2d1750cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound the second Modal read by the yield deadline

When Modal exposes the supported fallback read interface and both stdout and stderr reads take their 0.2-second timeout, a requested 250 ms yield still takes roughly 400 ms because the stderr read starts unconditionally after stdout has already consumed most or all of the deadline. The fresh evidence in this revision is that the new outer deadline guard skips later polls but does not recheck the deadline between these two sequential reads; check the remaining time before starting stderr or bound each read by the remaining yield window.

Useful? React with 👍 / 👎.

# the close signal that authorizes collector settlement and PTY removal.
async with entry.output_lock:
pass
entry.output_closed.set()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not close E2B output after an indeterminate wait failure

When handle.wait() fails with a transport or runtime exception that has no exit_code, the process may still be running, but this unconditional close signal makes every subsequent output collection return immediately and makes capacity pruning classify the entry as safely settled. That prevents normal long-poll behavior and can cause a still-running process to be preferentially pruned; publish output_closed only after a successful wait or a recognized terminal exception, or retain and surface the indeterminate waiter failure separately.

Useful? React with 👍 / 👎.

@ErenAta16ErenAta16 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.

Flagging the neighbourhood rather than the diff, since three open PRs are editing
these files at once and only one of the three overlaps is a real duplicate.

@Hughhhhcoder's #4750 and @mikemikimike's #4751 both landed the day after this one.
Reading the source changes, the three are addressing different defects:

#4738 threads output_closed through _collect_pty_output output settled before cleanup
#4750 except Exception -> BaseException in pty startup fds leak on cancellation
#4751 wraps _terminate_pty_entry in a cancellation shield teardown aborts on cancellation

So they are complementary, and none of them subsumes another. All three touch
sandbox/sandboxes/unix_local.py, at lines 354, 391/463 and 400/442 respectively,
which is close enough to be worth knowing about but far enough apart that the
hunks should not fight.

The real duplicate is the helper, not the call sites.#4750 adds
_settle_pty_cleanup as a module function in sandbox/session/pty_types.py, and
#4751 adds _settle_pty_cleanup as a method on BaseSandboxSession in
sandbox/session/base_sandbox_session.py. Same name, same shield-in-a-loop
algorithm, same completion.result() then task.result() sequence. Merging both
leaves the SDK with two of them.

They are not equivalent, and the difference shows up when cleanup itself fails
while the caller is being cancelled:

cleanup raises, caller cancelled
#4750 -> CancelledError propagates task.cancelled() = True
#4751 -> RuntimeError propagates task.cancelled() = False

#4750 gives the caller's cancellation priority over the cleanup error; #4751
reaches task.result() before its caller_cancellation check, so the cleanup
exception wins and the CancelledError is dropped. A task that was asked to stop
and then reports cancelled() is False is the case wait_for and TaskGroup
both rely on, so I would take #4750's ordering whichever module the helper ends
up living in.

One completeness note that may save a round trip on #4750: the
except Exception -> except BaseException change there is the only site of its
shape. I walked the AST of everything under sandbox/ and
extensions/sandbox/, looking for a try whose body contains an await and
whose sole handler is except Exception while closing a descriptor, and
unix_local.py:357 is the single match. entries/artifacts.py:714 looks similar
but its try body is fully synchronous, so Exception is sufficient there and it
is not an outlier.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@seratch@fscfede-beep@HuzaifaChaudary@ErenAta16
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(sandbox): settle PTY output before cleanup by seratch · Pull Request #4738 · openai/openai-agents-python · GitHub
Skip to content

fix(sandbox): settle PTY output before cleanup - #4738

Open
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output
Open

fix(sandbox): settle PTY output before cleanup#4738
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output

Conversation

@seratch

Copy link
Copy Markdown
Member

This pull request fixes PTY output settlement and supersedes #4572 and #4724. PTY collectors now re-drain output at timeout boundaries, carry only complete valid UTF-8 sequences across read windows, and make bounded replacement progress for invalid E0, ED, F0, and F4 prefixes.

Terminal cleanup now follows a collector-owned settled output_closed fact across local, Docker, E2B, Cloudflare, Modal, Blaxel, and Daytona adapters, so exit visibility cannot drop queued bytes or carried suffixes.

@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Aug 28, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-08-31T02:44:42.234416Z2b2d175New commits
🔒 Security ReviewCompleted2026-08-31T02:44:01.462995Z2b2d175New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:21c32b9985

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadsrc/agents/sandbox/session/pty_output.py Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch
seratchforce-pushed the fix/settle-pty-output branch from 21c32b9 to 41be368CompareAugust 28, 2026 10:12

@fscfede-beepfscfede-beep left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I re-audited the current 41be368c head specifically around cancellation ownership. Two cancellation boundaries can still lose bytes that this PR now intends to carry across PTY windows.

await wait_for_output(remaining_s)
else:
try:
await asyncio.wait_for(output_notify.wait(), timeout=remaining_s)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancellation can drop a UTF-8 prefix that a previous window deliberately carried for this surviving session. Deterministic ordering on current head: first window ends with b"\xc3", so _drain_and_carry_incomplete_suffix() leaves [b"\xc3"] on output_chunks; the next collection drains that lead byte into local output, reaches this output_notify wait, and is cancelled. There is no outer cancellation handler, so the deque is now empty. If b"\xa9" arrives later, the next/final collection sees the continuation alone and returns instead of é.

This is in-scope for this PR: before the new cross-window carry, a cancelled call could abandon its own drained window, but it could not consume persistent UTF-8 state intentionally handed forward by an earlier successful call and thereby corrupt later output.

The narrow fix is to make the drained local buffer transactional. Wrap collection/settlement in try/except asyncio.CancelledError; if output is non-empty, restore it synchronously to the front of output_chunks before re-raising (or keep ownership on the entry until commit). appendleft(bytes(output)) preserves these older bytes ahead of anything a producer queued while the call was running. Regression: cancel the second window while it is waiting here after draining the requeued b"\xc3"; assert the lead is back in the deque and a later b"\xa9" completes é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I turned this boundary into an executable control-flow reference against the 41be368c ownership shape. The current-shape repro drains a carried b"\xc3", cancels while the next window is waiting, and deterministically leaves the deque empty; the fixed reference restores the drained local buffer synchronously with appendleft(bytes(output)), then a later b"\xa9" reconstructs é. The focused reference suite also covers the Modal transfer below: 6/6 PASS (3 tests prove current bad behavior, 3 prove the proposed ownership repairs).

The narrow shared-collector patch contract is to put the existing collection + final settlement/drain/carry phase under try/except asyncio.CancelledError; if local output is non-empty, synchronously return it to the front of output_chunks before re-raising. The handler needs to cover cancellation from poll_output, waits, settle_output, and the final drain/carry lock acquisition. I have not run the upstream suite for this patch and am not opening a competing PR.

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Modal has the same survivor invariant one layer earlier: a successful stream read is consumed from the provider before poll_output() commits it to entry.output_chunks. Example on this branch: a previous window has carried b"\xc3" in entry.output_chunks; this call reads the continuation b"\xa9" from stdout, then is cancelled during this stderr await. The continuation lives only in local chunks, so it is lost while the older lead byte remains durable. A later call can then pair that lead with unrelated output or eventually replace it.

The exit drain has the same shape internally: _drain_modal_stream() accumulates consumed stream items in its own local bytearray across further awaits before returning them to poll_output(). Catching cancellation only in the outer shared collector therefore cannot recover bytes already removed from Modal's stream.

Please make each Modal ownership transfer cancellation-safe: either commit every successfully consumed stream chunk to entry-owned state before the next await, or catch CancelledError in both poll_output() and _drain_modal_stream() and synchronously restore their local consumed bytes to entry.output_chunks before propagating cancellation. Avoid an awaited lock acquisition as the only copy's next step unless cancellation around that acquisition also restores it. A regression should consume the UTF-8 continuation from stdout, block the following stderr read, cancel there, and verify the continuation remains entry-owned and the next collection reconstructs é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I also exercised this Modal ownership transfer independently. Current-shape repro: stdout consumes the continuation b"\xa9", stderr blocks, caller cancellation arrives, and the continuation never reaches entry.output_chunks; a separate exit-drain repro consumes b"tail", cancels on the next read, and loses that local buffer too. Both are deterministic.

The fixed reference passes by (1) moving each successful stdout/stderr read into local chunks immediately before the next await, (2) catching CancelledError in poll_output and synchronously returning any locally owned chunks to entry.output_chunks, clearing local ownership immediately after a normal queue commit to avoid replay, and (3) giving _drain_modal_stream the same cancellation-return rule for its bytearray. Together with the shared collector repair, the focused reference suite is 6/6 PASS. No provider integration execution or upstream patch application is claimed.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Cross-link for collision/maintainer coordination: #4745 was opened later against the same PTY cross-window UTF-8 / settlement area and has since accumulated a different implementation plus several cancellation/finalization fixes. I’m reviewing both rather than opening a third implementation. The two current cancellation findings I left on this PR (3885258586, 3885258589) are specific to this branch’s carry/Modal ownership boundaries; #4745 currently avoids them with different transaction state, but has had its own finalization lifecycle issues. Recommend treating #4738 and #4745 as competing solutions and selecting one architecture rather than merging them independently.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Selective salvage after comparing #4745 against current 41be368c: I do not think its whole patch should be ported here. Two of the three potentially useful pieces are already covered or architecture-specific: restricted E0/ED/F0/F4 prefix handling is already tested here, and #4745's source_text + final tail truncation repair addresses a post-collection tail path this branch does not use.

One test idea is still useful here: a controlled Unix lifecycle regression where process.returncode is already visible but the output pump still owns the continuation of a split UTF-8 character. Hold the pump before it appends the continuation, assert the first collection/finalization keeps the session live and retains the lead byte, release the pump, then assert the next update returns é and closes. The current implementation looks correct by inspection because _watch_process_exit sets output_closed only after gather(*pump_tasks), but tests/sandbox/test_unix_local.py does not currently pin that ordering.

I would add that regression only after/alongside the two cancellation ownership fixes already posted in 3885258586 and 3885258589. No third competing PR from me.

@HuzaifaChaudary

Copy link
Copy Markdown

hi @seratch. i had a pull request in this area, #4745, which i have just closed in favour of this one. it predates mine and covers the same ground, so there is nothing to weigh up there.

@fscfede-beep reviewed both and suggested one thing from mine might still be worth having, so i am leaving it here as an idea rather than a patch. take it or ignore it.

this pr has the producer drained output_closed design already. the test below pins the ordering it protects, a process reaped while a pump still owns the continuation, which is the case where finalising on the exit code alone silently turns é into :

entry=_UnixPtyProcessEntry(process=process, tty=True) # returncode 0# output_closed deliberately NOT set, the pump still holds the continuationentry.output_chunks.append("é".encode()[:1])
# first update must leave the session alive with the lead byte still queuedassertfirst.process_id==1assertfirst.exit_codeisNoneassertlist(entry.output_chunks) == ["é".encode()[:1]]
# then the pump delivers and closesentry.output_chunks.append("é".encode()[1:])
entry.output_closed.set()
assertfinal.output.decode("utf-8") =="é"assertfinal.process_idisNone

what made me think it earns its place is that it fails for the right reason. swapping the predicate back to entry.process.returncode on its own gives:

PtyExecUpdate(process_id=None, output=b'\xef\xbf\xbd', exit_code=0, original_token_count=None)

so it catches a future tidy up that undoes the predicate, which is a one line change that reads like cleanup. the full version is in the closed branch at HuzaifaChaudary/openai-agents-python@78b5c4d, tests/sandbox/test_unix_local.py::TestUnixLocalPty::test_session_is_not_finalized_while_a_pump_still_holds_output.

happy to open it as a small test only pull request against this branch if you want it, or to leave it entirely. no need to reply if not.

@HuzaifaChaudary

Copy link
Copy Markdown

@seratch, short follow up to my note above and then i will stop.

the two cancellation findings @fscfede-beep raised on this pr are the same two i hit and fixed on the branch i closed, so there is working tested code for both if it saves you deriving them again. commits are on HuzaifaChaudary/openai-agents-python, against my own shape rather than yours, so they are a reference not a patch.

shared collector, 2070572e. exactly the contract he describes:

exceptasyncio.CancelledError:
ifoutput:
output_chunks.appendleft(bytes(output))
raise

modal, bf682c80. the whole buffer goes to entry owned state, carried tail and new reads together, with the try covering both stream reads, the exit poll, the sleep, the drains and the final poll. assigning rather than appending is what stops the carried tail replaying, since chunks already starts with it.

each has a regression that fails on the commit before it. the modal one consumes the continuation off the stream, cancels on the next await, and asserts both halves are still entry owned.

one thing worth flagging, because it bit me right after i fixed these. once the tail flush moved in front of the registry pop, cancelling during the drain skipped _terminate_pty_entry entirely, and since the entry was already out of the map nothing could clean it up later. on blaxel that leaves a websocket and an aiohttp.ClientSession open. 78b5c4d0 puts the drain in a try with the terminate in the finally. worth checking whichever order this branch lands on.

that plus the blocked pump test above is everything i have. no reply needed, and i am not opening anything here.

Co-authored-by: Henry Su <henrysu4707@gmail.com>
Co-authored-by: ayaangazali <ayaangazali.work@gmail.com>
@seratch
seratchforce-pushed the fix/settle-pty-output branch from 41be368 to 2b2d175CompareAugust 31, 2026 02:39

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2b2d1750cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound the second Modal read by the yield deadline

When Modal exposes the supported fallback read interface and both stdout and stderr reads take their 0.2-second timeout, a requested 250 ms yield still takes roughly 400 ms because the stderr read starts unconditionally after stdout has already consumed most or all of the deadline. The fresh evidence in this revision is that the new outer deadline guard skips later polls but does not recheck the deadline between these two sequential reads; check the remaining time before starting stderr or bound each read by the remaining yield window.

Useful? React with 👍 / 👎.

# the close signal that authorizes collector settlement and PTY removal.
async with entry.output_lock:
pass
entry.output_closed.set()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not close E2B output after an indeterminate wait failure

When handle.wait() fails with a transport or runtime exception that has no exit_code, the process may still be running, but this unconditional close signal makes every subsequent output collection return immediately and makes capacity pruning classify the entry as safely settled. That prevents normal long-poll behavior and can cause a still-running process to be preferentially pruned; publish output_closed only after a successful wait or a recognized terminal exception, or retain and surface the indeterminate waiter failure separately.

Useful? React with 👍 / 👎.

@ErenAta16ErenAta16 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.

Flagging the neighbourhood rather than the diff, since three open PRs are editing
these files at once and only one of the three overlaps is a real duplicate.

@Hughhhhcoder's #4750 and @mikemikimike's #4751 both landed the day after this one.
Reading the source changes, the three are addressing different defects:

#4738 threads output_closed through _collect_pty_output output settled before cleanup
#4750 except Exception -> BaseException in pty startup fds leak on cancellation
#4751 wraps _terminate_pty_entry in a cancellation shield teardown aborts on cancellation

So they are complementary, and none of them subsumes another. All three touch
sandbox/sandboxes/unix_local.py, at lines 354, 391/463 and 400/442 respectively,
which is close enough to be worth knowing about but far enough apart that the
hunks should not fight.

The real duplicate is the helper, not the call sites.#4750 adds
_settle_pty_cleanup as a module function in sandbox/session/pty_types.py, and
#4751 adds _settle_pty_cleanup as a method on BaseSandboxSession in
sandbox/session/base_sandbox_session.py. Same name, same shield-in-a-loop
algorithm, same completion.result() then task.result() sequence. Merging both
leaves the SDK with two of them.

They are not equivalent, and the difference shows up when cleanup itself fails
while the caller is being cancelled:

cleanup raises, caller cancelled
#4750 -> CancelledError propagates task.cancelled() = True
#4751 -> RuntimeError propagates task.cancelled() = False

#4750 gives the caller's cancellation priority over the cleanup error; #4751
reaches task.result() before its caller_cancellation check, so the cleanup
exception wins and the CancelledError is dropped. A task that was asked to stop
and then reports cancelled() is False is the case wait_for and TaskGroup
both rely on, so I would take #4750's ordering whichever module the helper ends
up living in.

One completeness note that may save a round trip on #4750: the
except Exception -> except BaseException change there is the only site of its
shape. I walked the AST of everything under sandbox/ and
extensions/sandbox/, looking for a try whose body contains an await and
whose sole handler is except Exception while closing a descriptor, and
unix_local.py:357 is the single match. entries/artifacts.py:714 looks similar
but its try body is fully synchronous, so Exception is sufficient there and it
is not an outlier.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@seratch@fscfede-beep@HuzaifaChaudary@ErenAta16
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(sandbox): settle PTY output before cleanup by seratch · Pull Request #4738 · openai/openai-agents-python · GitHub
Skip to content

fix(sandbox): settle PTY output before cleanup - #4738

Open
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output
Open

fix(sandbox): settle PTY output before cleanup#4738
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output

Conversation

@seratch

Copy link
Copy Markdown
Member

This pull request fixes PTY output settlement and supersedes #4572 and #4724. PTY collectors now re-drain output at timeout boundaries, carry only complete valid UTF-8 sequences across read windows, and make bounded replacement progress for invalid E0, ED, F0, and F4 prefixes.

Terminal cleanup now follows a collector-owned settled output_closed fact across local, Docker, E2B, Cloudflare, Modal, Blaxel, and Daytona adapters, so exit visibility cannot drop queued bytes or carried suffixes.

@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Aug 28, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-08-31T02:44:42.234416Z2b2d175New commits
🔒 Security ReviewCompleted2026-08-31T02:44:01.462995Z2b2d175New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:21c32b9985

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadsrc/agents/sandbox/session/pty_output.py Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch
seratchforce-pushed the fix/settle-pty-output branch from 21c32b9 to 41be368CompareAugust 28, 2026 10:12

@fscfede-beepfscfede-beep left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I re-audited the current 41be368c head specifically around cancellation ownership. Two cancellation boundaries can still lose bytes that this PR now intends to carry across PTY windows.

await wait_for_output(remaining_s)
else:
try:
await asyncio.wait_for(output_notify.wait(), timeout=remaining_s)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancellation can drop a UTF-8 prefix that a previous window deliberately carried for this surviving session. Deterministic ordering on current head: first window ends with b"\xc3", so _drain_and_carry_incomplete_suffix() leaves [b"\xc3"] on output_chunks; the next collection drains that lead byte into local output, reaches this output_notify wait, and is cancelled. There is no outer cancellation handler, so the deque is now empty. If b"\xa9" arrives later, the next/final collection sees the continuation alone and returns instead of é.

This is in-scope for this PR: before the new cross-window carry, a cancelled call could abandon its own drained window, but it could not consume persistent UTF-8 state intentionally handed forward by an earlier successful call and thereby corrupt later output.

The narrow fix is to make the drained local buffer transactional. Wrap collection/settlement in try/except asyncio.CancelledError; if output is non-empty, restore it synchronously to the front of output_chunks before re-raising (or keep ownership on the entry until commit). appendleft(bytes(output)) preserves these older bytes ahead of anything a producer queued while the call was running. Regression: cancel the second window while it is waiting here after draining the requeued b"\xc3"; assert the lead is back in the deque and a later b"\xa9" completes é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I turned this boundary into an executable control-flow reference against the 41be368c ownership shape. The current-shape repro drains a carried b"\xc3", cancels while the next window is waiting, and deterministically leaves the deque empty; the fixed reference restores the drained local buffer synchronously with appendleft(bytes(output)), then a later b"\xa9" reconstructs é. The focused reference suite also covers the Modal transfer below: 6/6 PASS (3 tests prove current bad behavior, 3 prove the proposed ownership repairs).

The narrow shared-collector patch contract is to put the existing collection + final settlement/drain/carry phase under try/except asyncio.CancelledError; if local output is non-empty, synchronously return it to the front of output_chunks before re-raising. The handler needs to cover cancellation from poll_output, waits, settle_output, and the final drain/carry lock acquisition. I have not run the upstream suite for this patch and am not opening a competing PR.

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Modal has the same survivor invariant one layer earlier: a successful stream read is consumed from the provider before poll_output() commits it to entry.output_chunks. Example on this branch: a previous window has carried b"\xc3" in entry.output_chunks; this call reads the continuation b"\xa9" from stdout, then is cancelled during this stderr await. The continuation lives only in local chunks, so it is lost while the older lead byte remains durable. A later call can then pair that lead with unrelated output or eventually replace it.

The exit drain has the same shape internally: _drain_modal_stream() accumulates consumed stream items in its own local bytearray across further awaits before returning them to poll_output(). Catching cancellation only in the outer shared collector therefore cannot recover bytes already removed from Modal's stream.

Please make each Modal ownership transfer cancellation-safe: either commit every successfully consumed stream chunk to entry-owned state before the next await, or catch CancelledError in both poll_output() and _drain_modal_stream() and synchronously restore their local consumed bytes to entry.output_chunks before propagating cancellation. Avoid an awaited lock acquisition as the only copy's next step unless cancellation around that acquisition also restores it. A regression should consume the UTF-8 continuation from stdout, block the following stderr read, cancel there, and verify the continuation remains entry-owned and the next collection reconstructs é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I also exercised this Modal ownership transfer independently. Current-shape repro: stdout consumes the continuation b"\xa9", stderr blocks, caller cancellation arrives, and the continuation never reaches entry.output_chunks; a separate exit-drain repro consumes b"tail", cancels on the next read, and loses that local buffer too. Both are deterministic.

The fixed reference passes by (1) moving each successful stdout/stderr read into local chunks immediately before the next await, (2) catching CancelledError in poll_output and synchronously returning any locally owned chunks to entry.output_chunks, clearing local ownership immediately after a normal queue commit to avoid replay, and (3) giving _drain_modal_stream the same cancellation-return rule for its bytearray. Together with the shared collector repair, the focused reference suite is 6/6 PASS. No provider integration execution or upstream patch application is claimed.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Cross-link for collision/maintainer coordination: #4745 was opened later against the same PTY cross-window UTF-8 / settlement area and has since accumulated a different implementation plus several cancellation/finalization fixes. I’m reviewing both rather than opening a third implementation. The two current cancellation findings I left on this PR (3885258586, 3885258589) are specific to this branch’s carry/Modal ownership boundaries; #4745 currently avoids them with different transaction state, but has had its own finalization lifecycle issues. Recommend treating #4738 and #4745 as competing solutions and selecting one architecture rather than merging them independently.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Selective salvage after comparing #4745 against current 41be368c: I do not think its whole patch should be ported here. Two of the three potentially useful pieces are already covered or architecture-specific: restricted E0/ED/F0/F4 prefix handling is already tested here, and #4745's source_text + final tail truncation repair addresses a post-collection tail path this branch does not use.

One test idea is still useful here: a controlled Unix lifecycle regression where process.returncode is already visible but the output pump still owns the continuation of a split UTF-8 character. Hold the pump before it appends the continuation, assert the first collection/finalization keeps the session live and retains the lead byte, release the pump, then assert the next update returns é and closes. The current implementation looks correct by inspection because _watch_process_exit sets output_closed only after gather(*pump_tasks), but tests/sandbox/test_unix_local.py does not currently pin that ordering.

I would add that regression only after/alongside the two cancellation ownership fixes already posted in 3885258586 and 3885258589. No third competing PR from me.

@HuzaifaChaudary

Copy link
Copy Markdown

hi @seratch. i had a pull request in this area, #4745, which i have just closed in favour of this one. it predates mine and covers the same ground, so there is nothing to weigh up there.

@fscfede-beep reviewed both and suggested one thing from mine might still be worth having, so i am leaving it here as an idea rather than a patch. take it or ignore it.

this pr has the producer drained output_closed design already. the test below pins the ordering it protects, a process reaped while a pump still owns the continuation, which is the case where finalising on the exit code alone silently turns é into :

entry=_UnixPtyProcessEntry(process=process, tty=True) # returncode 0# output_closed deliberately NOT set, the pump still holds the continuationentry.output_chunks.append("é".encode()[:1])
# first update must leave the session alive with the lead byte still queuedassertfirst.process_id==1assertfirst.exit_codeisNoneassertlist(entry.output_chunks) == ["é".encode()[:1]]
# then the pump delivers and closesentry.output_chunks.append("é".encode()[1:])
entry.output_closed.set()
assertfinal.output.decode("utf-8") =="é"assertfinal.process_idisNone

what made me think it earns its place is that it fails for the right reason. swapping the predicate back to entry.process.returncode on its own gives:

PtyExecUpdate(process_id=None, output=b'\xef\xbf\xbd', exit_code=0, original_token_count=None)

so it catches a future tidy up that undoes the predicate, which is a one line change that reads like cleanup. the full version is in the closed branch at HuzaifaChaudary/openai-agents-python@78b5c4d, tests/sandbox/test_unix_local.py::TestUnixLocalPty::test_session_is_not_finalized_while_a_pump_still_holds_output.

happy to open it as a small test only pull request against this branch if you want it, or to leave it entirely. no need to reply if not.

@HuzaifaChaudary

Copy link
Copy Markdown

@seratch, short follow up to my note above and then i will stop.

the two cancellation findings @fscfede-beep raised on this pr are the same two i hit and fixed on the branch i closed, so there is working tested code for both if it saves you deriving them again. commits are on HuzaifaChaudary/openai-agents-python, against my own shape rather than yours, so they are a reference not a patch.

shared collector, 2070572e. exactly the contract he describes:

exceptasyncio.CancelledError:
ifoutput:
output_chunks.appendleft(bytes(output))
raise

modal, bf682c80. the whole buffer goes to entry owned state, carried tail and new reads together, with the try covering both stream reads, the exit poll, the sleep, the drains and the final poll. assigning rather than appending is what stops the carried tail replaying, since chunks already starts with it.

each has a regression that fails on the commit before it. the modal one consumes the continuation off the stream, cancels on the next await, and asserts both halves are still entry owned.

one thing worth flagging, because it bit me right after i fixed these. once the tail flush moved in front of the registry pop, cancelling during the drain skipped _terminate_pty_entry entirely, and since the entry was already out of the map nothing could clean it up later. on blaxel that leaves a websocket and an aiohttp.ClientSession open. 78b5c4d0 puts the drain in a try with the terminate in the finally. worth checking whichever order this branch lands on.

that plus the blocked pump test above is everything i have. no reply needed, and i am not opening anything here.

Co-authored-by: Henry Su <henrysu4707@gmail.com>
Co-authored-by: ayaangazali <ayaangazali.work@gmail.com>
@seratch
seratchforce-pushed the fix/settle-pty-output branch from 41be368 to 2b2d175CompareAugust 31, 2026 02:39

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2b2d1750cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound the second Modal read by the yield deadline

When Modal exposes the supported fallback read interface and both stdout and stderr reads take their 0.2-second timeout, a requested 250 ms yield still takes roughly 400 ms because the stderr read starts unconditionally after stdout has already consumed most or all of the deadline. The fresh evidence in this revision is that the new outer deadline guard skips later polls but does not recheck the deadline between these two sequential reads; check the remaining time before starting stderr or bound each read by the remaining yield window.

Useful? React with 👍 / 👎.

# the close signal that authorizes collector settlement and PTY removal.
async with entry.output_lock:
pass
entry.output_closed.set()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not close E2B output after an indeterminate wait failure

When handle.wait() fails with a transport or runtime exception that has no exit_code, the process may still be running, but this unconditional close signal makes every subsequent output collection return immediately and makes capacity pruning classify the entry as safely settled. That prevents normal long-poll behavior and can cause a still-running process to be preferentially pruned; publish output_closed only after a successful wait or a recognized terminal exception, or retain and surface the indeterminate waiter failure separately.

Useful? React with 👍 / 👎.

@ErenAta16ErenAta16 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.

Flagging the neighbourhood rather than the diff, since three open PRs are editing
these files at once and only one of the three overlaps is a real duplicate.

@Hughhhhcoder's #4750 and @mikemikimike's #4751 both landed the day after this one.
Reading the source changes, the three are addressing different defects:

#4738 threads output_closed through _collect_pty_output output settled before cleanup
#4750 except Exception -> BaseException in pty startup fds leak on cancellation
#4751 wraps _terminate_pty_entry in a cancellation shield teardown aborts on cancellation

So they are complementary, and none of them subsumes another. All three touch
sandbox/sandboxes/unix_local.py, at lines 354, 391/463 and 400/442 respectively,
which is close enough to be worth knowing about but far enough apart that the
hunks should not fight.

The real duplicate is the helper, not the call sites.#4750 adds
_settle_pty_cleanup as a module function in sandbox/session/pty_types.py, and
#4751 adds _settle_pty_cleanup as a method on BaseSandboxSession in
sandbox/session/base_sandbox_session.py. Same name, same shield-in-a-loop
algorithm, same completion.result() then task.result() sequence. Merging both
leaves the SDK with two of them.

They are not equivalent, and the difference shows up when cleanup itself fails
while the caller is being cancelled:

cleanup raises, caller cancelled
#4750 -> CancelledError propagates task.cancelled() = True
#4751 -> RuntimeError propagates task.cancelled() = False

#4750 gives the caller's cancellation priority over the cleanup error; #4751
reaches task.result() before its caller_cancellation check, so the cleanup
exception wins and the CancelledError is dropped. A task that was asked to stop
and then reports cancelled() is False is the case wait_for and TaskGroup
both rely on, so I would take #4750's ordering whichever module the helper ends
up living in.

One completeness note that may save a round trip on #4750: the
except Exception -> except BaseException change there is the only site of its
shape. I walked the AST of everything under sandbox/ and
extensions/sandbox/, looking for a try whose body contains an await and
whose sole handler is except Exception while closing a descriptor, and
unix_local.py:357 is the single match. entries/artifacts.py:714 looks similar
but its try body is fully synchronous, so Exception is sufficient there and it
is not an outlier.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@seratch@fscfede-beep@HuzaifaChaudary@ErenAta16
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(sandbox): settle PTY output before cleanup by seratch · Pull Request #4738 · openai/openai-agents-python · GitHub
Skip to content

fix(sandbox): settle PTY output before cleanup - #4738

Open
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output
Open

fix(sandbox): settle PTY output before cleanup#4738
seratch wants to merge 1 commit into
mainfrom
fix/settle-pty-output

Conversation

@seratch

Copy link
Copy Markdown
Member

This pull request fixes PTY output settlement and supersedes #4572 and #4724. PTY collectors now re-drain output at timeout boundaries, carry only complete valid UTF-8 sequences across read windows, and make bounded replacement progress for invalid E0, ED, F0, and F4 prefixes.

Terminal cleanup now follows a collector-owned settled output_closed fact across local, Docker, E2B, Cloudflare, Modal, Blaxel, and Daytona adapters, so exit visibility cannot drop queued bytes or carried suffixes.

@chatgpt-codex-connector

chatgpt-codex-connectorBot commented Aug 28, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code ReviewCompleted2026-08-31T02:44:42.234416Z2b2d175New commits
🔒 Security ReviewCompleted2026-08-31T02:44:01.462995Z2b2d175New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:21c32b9985

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment threadsrc/agents/sandbox/session/pty_output.py Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

An unknown error occurred
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch
seratchforce-pushed the fix/settle-pty-output branch from 21c32b9 to 41be368CompareAugust 28, 2026 10:12

@fscfede-beepfscfede-beep left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I re-audited the current 41be368c head specifically around cancellation ownership. Two cancellation boundaries can still lose bytes that this PR now intends to carry across PTY windows.

await wait_for_output(remaining_s)
else:
try:
await asyncio.wait_for(output_notify.wait(), timeout=remaining_s)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cancellation can drop a UTF-8 prefix that a previous window deliberately carried for this surviving session. Deterministic ordering on current head: first window ends with b"\xc3", so _drain_and_carry_incomplete_suffix() leaves [b"\xc3"] on output_chunks; the next collection drains that lead byte into local output, reaches this output_notify wait, and is cancelled. There is no outer cancellation handler, so the deque is now empty. If b"\xa9" arrives later, the next/final collection sees the continuation alone and returns instead of é.

This is in-scope for this PR: before the new cross-window carry, a cancelled call could abandon its own drained window, but it could not consume persistent UTF-8 state intentionally handed forward by an earlier successful call and thereby corrupt later output.

The narrow fix is to make the drained local buffer transactional. Wrap collection/settlement in try/except asyncio.CancelledError; if output is non-empty, restore it synchronously to the front of output_chunks before re-raising (or keep ownership on the entry until commit). appendleft(bytes(output)) preserves these older bytes ahead of anything a producer queued while the call was running. Regression: cancel the second window while it is waiting here after draining the requeued b"\xc3"; assert the lead is back in the deque and a later b"\xa9" completes é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I turned this boundary into an executable control-flow reference against the 41be368c ownership shape. The current-shape repro drains a carried b"\xc3", cancels while the next window is waiting, and deterministically leaves the deque empty; the fixed reference restores the drained local buffer synchronously with appendleft(bytes(output)), then a later b"\xa9" reconstructs é. The focused reference suite also covers the Modal transfer below: 6/6 PASS (3 tests prove current bad behavior, 3 prove the proposed ownership repairs).

The narrow shared-collector patch contract is to put the existing collection + final settlement/drain/carry phase under try/except asyncio.CancelledError; if local output is non-empty, synchronously return it to the front of output_chunks before re-raising. The handler needs to cover cancellation from poll_output, waits, settle_output, and the final drain/carry lock acquisition. I have not run the upstream suite for this patch and am not opening a competing PR.

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Modal has the same survivor invariant one layer earlier: a successful stream read is consumed from the provider before poll_output() commits it to entry.output_chunks. Example on this branch: a previous window has carried b"\xc3" in entry.output_chunks; this call reads the continuation b"\xa9" from stdout, then is cancelled during this stderr await. The continuation lives only in local chunks, so it is lost while the older lead byte remains durable. A later call can then pair that lead with unrelated output or eventually replace it.

The exit drain has the same shape internally: _drain_modal_stream() accumulates consumed stream items in its own local bytearray across further awaits before returning them to poll_output(). Catching cancellation only in the outer shared collector therefore cannot recover bytes already removed from Modal's stream.

Please make each Modal ownership transfer cancellation-safe: either commit every successfully consumed stream chunk to entry-owned state before the next await, or catch CancelledError in both poll_output() and _drain_modal_stream() and synchronously restore their local consumed bytes to entry.output_chunks before propagating cancellation. Avoid an awaited lock acquisition as the only copy's next step unless cancellation around that acquisition also restores it. A regression should consume the UTF-8 continuation from stdout, block the following stderr read, cancel there, and verify the continuation remains entry-owned and the next collection reconstructs é.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I also exercised this Modal ownership transfer independently. Current-shape repro: stdout consumes the continuation b"\xa9", stderr blocks, caller cancellation arrives, and the continuation never reaches entry.output_chunks; a separate exit-drain repro consumes b"tail", cancels on the next read, and loses that local buffer too. Both are deterministic.

The fixed reference passes by (1) moving each successful stdout/stderr read into local chunks immediately before the next await, (2) catching CancelledError in poll_output and synchronously returning any locally owned chunks to entry.output_chunks, clearing local ownership immediately after a normal queue commit to avoid replay, and (3) giving _drain_modal_stream the same cancellation-return rule for its bytearray. Together with the shared collector repair, the focused reference suite is 6/6 PASS. No provider integration execution or upstream patch application is claimed.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Cross-link for collision/maintainer coordination: #4745 was opened later against the same PTY cross-window UTF-8 / settlement area and has since accumulated a different implementation plus several cancellation/finalization fixes. I’m reviewing both rather than opening a third implementation. The two current cancellation findings I left on this PR (3885258586, 3885258589) are specific to this branch’s carry/Modal ownership boundaries; #4745 currently avoids them with different transaction state, but has had its own finalization lifecycle issues. Recommend treating #4738 and #4745 as competing solutions and selecting one architecture rather than merging them independently.

@fscfede-beepChatGPT Codex Connector

Copy link
Copy Markdown

Selective salvage after comparing #4745 against current 41be368c: I do not think its whole patch should be ported here. Two of the three potentially useful pieces are already covered or architecture-specific: restricted E0/ED/F0/F4 prefix handling is already tested here, and #4745's source_text + final tail truncation repair addresses a post-collection tail path this branch does not use.

One test idea is still useful here: a controlled Unix lifecycle regression where process.returncode is already visible but the output pump still owns the continuation of a split UTF-8 character. Hold the pump before it appends the continuation, assert the first collection/finalization keeps the session live and retains the lead byte, release the pump, then assert the next update returns é and closes. The current implementation looks correct by inspection because _watch_process_exit sets output_closed only after gather(*pump_tasks), but tests/sandbox/test_unix_local.py does not currently pin that ordering.

I would add that regression only after/alongside the two cancellation ownership fixes already posted in 3885258586 and 3885258589. No third competing PR from me.

@HuzaifaChaudary

Copy link
Copy Markdown

hi @seratch. i had a pull request in this area, #4745, which i have just closed in favour of this one. it predates mine and covers the same ground, so there is nothing to weigh up there.

@fscfede-beep reviewed both and suggested one thing from mine might still be worth having, so i am leaving it here as an idea rather than a patch. take it or ignore it.

this pr has the producer drained output_closed design already. the test below pins the ordering it protects, a process reaped while a pump still owns the continuation, which is the case where finalising on the exit code alone silently turns é into :

entry=_UnixPtyProcessEntry(process=process, tty=True) # returncode 0# output_closed deliberately NOT set, the pump still holds the continuationentry.output_chunks.append("é".encode()[:1])
# first update must leave the session alive with the lead byte still queuedassertfirst.process_id==1assertfirst.exit_codeisNoneassertlist(entry.output_chunks) == ["é".encode()[:1]]
# then the pump delivers and closesentry.output_chunks.append("é".encode()[1:])
entry.output_closed.set()
assertfinal.output.decode("utf-8") =="é"assertfinal.process_idisNone

what made me think it earns its place is that it fails for the right reason. swapping the predicate back to entry.process.returncode on its own gives:

PtyExecUpdate(process_id=None, output=b'\xef\xbf\xbd', exit_code=0, original_token_count=None)

so it catches a future tidy up that undoes the predicate, which is a one line change that reads like cleanup. the full version is in the closed branch at HuzaifaChaudary/openai-agents-python@78b5c4d, tests/sandbox/test_unix_local.py::TestUnixLocalPty::test_session_is_not_finalized_while_a_pump_still_holds_output.

happy to open it as a small test only pull request against this branch if you want it, or to leave it entirely. no need to reply if not.

@HuzaifaChaudary

Copy link
Copy Markdown

@seratch, short follow up to my note above and then i will stop.

the two cancellation findings @fscfede-beep raised on this pr are the same two i hit and fixed on the branch i closed, so there is working tested code for both if it saves you deriving them again. commits are on HuzaifaChaudary/openai-agents-python, against my own shape rather than yours, so they are a reference not a patch.

shared collector, 2070572e. exactly the contract he describes:

exceptasyncio.CancelledError:
ifoutput:
output_chunks.appendleft(bytes(output))
raise

modal, bf682c80. the whole buffer goes to entry owned state, carried tail and new reads together, with the try covering both stream reads, the exit poll, the sleep, the drains and the final poll. assigning rather than appending is what stops the carried tail replaying, since chunks already starts with it.

each has a regression that fails on the commit before it. the modal one consumes the continuation off the stream, cancels on the next await, and asserts both halves are still entry owned.

one thing worth flagging, because it bit me right after i fixed these. once the tail flush moved in front of the registry pop, cancelling during the drain skipped _terminate_pty_entry entirely, and since the entry was already out of the map nothing could clean it up later. on blaxel that leaves a websocket and an aiohttp.ClientSession open. 78b5c4d0 puts the drain in a try with the terminate in the finally. worth checking whichever order this branch lands on.

that plus the blocked pump test above is everything i have. no reply needed, and i am not opening anything here.

Co-authored-by: Henry Su <henrysu4707@gmail.com>
Co-authored-by: ayaangazali <ayaangazali.work@gmail.com>
@seratch
seratchforce-pushed the fix/settle-pty-output branch from 41be368 to 2b2d175CompareAugust 31, 2026 02:39

@chatgpt-codex-connectorchatgpt-codex-connectorBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:2b2d1750cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

stream_name="stdout",
allow_new_read=allow_new_read,
)
stderr_chunk = await self._read_modal_stream(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound the second Modal read by the yield deadline

When Modal exposes the supported fallback read interface and both stdout and stderr reads take their 0.2-second timeout, a requested 250 ms yield still takes roughly 400 ms because the stderr read starts unconditionally after stdout has already consumed most or all of the deadline. The fresh evidence in this revision is that the new outer deadline guard skips later polls but does not recheck the deadline between these two sequential reads; check the remaining time before starting stderr or bound each read by the remaining yield window.

Useful? React with 👍 / 👎.

# the close signal that authorizes collector settlement and PTY removal.
async with entry.output_lock:
pass
entry.output_closed.set()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not close E2B output after an indeterminate wait failure

When handle.wait() fails with a transport or runtime exception that has no exit_code, the process may still be running, but this unconditional close signal makes every subsequent output collection return immediately and makes capacity pruning classify the entry as safely settled. That prevents normal long-poll behavior and can cause a still-running process to be preferentially pruned; publish output_closed only after a successful wait or a recognized terminal exception, or retain and surface the indeterminate waiter failure separately.

Useful? React with 👍 / 👎.

@ErenAta16ErenAta16 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.

Flagging the neighbourhood rather than the diff, since three open PRs are editing
these files at once and only one of the three overlaps is a real duplicate.

@Hughhhhcoder's #4750 and @mikemikimike's #4751 both landed the day after this one.
Reading the source changes, the three are addressing different defects:

#4738 threads output_closed through _collect_pty_output output settled before cleanup
#4750 except Exception -> BaseException in pty startup fds leak on cancellation
#4751 wraps _terminate_pty_entry in a cancellation shield teardown aborts on cancellation

So they are complementary, and none of them subsumes another. All three touch
sandbox/sandboxes/unix_local.py, at lines 354, 391/463 and 400/442 respectively,
which is close enough to be worth knowing about but far enough apart that the
hunks should not fight.

The real duplicate is the helper, not the call sites.#4750 adds
_settle_pty_cleanup as a module function in sandbox/session/pty_types.py, and
#4751 adds _settle_pty_cleanup as a method on BaseSandboxSession in
sandbox/session/base_sandbox_session.py. Same name, same shield-in-a-loop
algorithm, same completion.result() then task.result() sequence. Merging both
leaves the SDK with two of them.

They are not equivalent, and the difference shows up when cleanup itself fails
while the caller is being cancelled:

cleanup raises, caller cancelled
#4750 -> CancelledError propagates task.cancelled() = True
#4751 -> RuntimeError propagates task.cancelled() = False

#4750 gives the caller's cancellation priority over the cleanup error; #4751
reaches task.result() before its caller_cancellation check, so the cleanup
exception wins and the CancelledError is dropped. A task that was asked to stop
and then reports cancelled() is False is the case wait_for and TaskGroup
both rely on, so I would take #4750's ordering whichever module the helper ends
up living in.

One completeness note that may save a round trip on #4750: the
except Exception -> except BaseException change there is the only site of its
shape. I walked the AST of everything under sandbox/ and
extensions/sandbox/, looking for a try whose body contains an await and
whose sole handler is except Exception while closing a descriptor, and
unix_local.py:357 is the single match. entries/artifacts.py:714 looks similar
but its try body is fully synchronous, so Exception is sufficient there and it
is not an outlier.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@seratch@fscfede-beep@HuzaifaChaudary@ErenAta16