Conversation
|
@strandly-the-agent can you review it? I dont know if it is designed? |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Approve — the design is right and the unsafe impl Send is sound. Two should-fix comment/doc nits inline; nothing blocking, no public API change.
To JackYPCOnline's question — is this the intended design? Yes, in my view:
- Sound, and the pattern PyO3 itself describes.
Python::detachruns its closure inline on the calling thread (pyo3 0.29marker.rs:562-573), so nothing inSameThreadever changes thread. PyO3's own docs say "there is no reason to prevent!Sendtypes likeRcfrom crossing the closure…detachdoes not itself launch a new thread". The real invariant is no Python references inside the closure, and that holds: nothing outsidesrc/python.rsuses pyo3 (grep -rn pyo3 src --include=*.rs | grep -v python.rs→ 0 hits). - Smallest fix. It is the first
unsafeinsrc/(only test env-var code had one), confined to one private type. The SAFETY comment argues the wrong invariant, though — see inline. - Worker-thread alternative (mirroring
js.rs) would be more code but would also lift theunsendablerestriction: today aShellcan only be used from the thread that created it, soasyncio.to_thread(shell.run, …)or a thread-pool tool raisesPanicException. That is pre-existing and orthogonal to #119 — I'd keep this PR as-is and track the cross-thread story as a follow-up issue if you want it.
✅ Verified (head 8a5637c, Python 3.13.15, aarch64)
maturin develop --release+pytest tests/python -v→ 45 passed;test_run_releases_the_gilpassed 5/5 reruns.- Issue #119 repro: 1890 ticks during
Shell().run("sleep 2")vs 1884 duringtime.sleep(2). - Cross-thread call on a shared Shell →
PanicException(same as main; the check is in PyO3's trampoline, untouched). 4 Shells on 4 threads ×sleep 1→ wall 1.00s.Shell(timeout=0.3).run("sleep 5")returns at 0.30s throughdetach. Panic inside a command leaves the Shell, GIL and other threads usable. Drop on another thread → PyO3's unraisable + leak, pre-existing. - Test margin:
during > 50measured 471–476 ticks idle and with 2 external busy processes; drops to 9–44 only with CPU-bound Python threads in the same process, which the suite doesn't have. cargo fmt --checkclean;cargo clippy --features python --lib→ 0 warnings insrc/python.rs(6 pre-existing elsewhere).- All five
block_onsites converted; noblock_onleft outside the helper. No.pyistubs or CHANGELOG to update. - Repro scripts/outputs and the pytest log are uploaded as review artifacts.
Questions (non-blocking)
ShellBuilder.build(src/python.rs:414-423) still holds the GIL, and that's where copy-mode binds do their host I/O (vfs::copy_from_host,src/vfs_config.rs:273-275);js.rs:389treats build as async for that reason. Measured 140 ms / 1 tick for a 93 MB copy bind. Deliberately out of scope for #119?- Want a follow-up issue for the
unsendable/ cross-thread limitation (worker-thread design)? I can file it.
Reading order
src/python.rs:426-450 (wrapper + helper) → one call site, e.g. :478-492 → tests/python/test_bindings.py:273-294.
Appendix — non-blocking (3)
- ⚪
src/python.rsmodule doc has no threading-model note whilesrc/js.rs:12-20does; a 2–3 line "GIL released per I/O method, runtime runs inline, cross-thread use rejected byunsendable" would keep the mirror symmetric (AGENTS.md:26). - ⚪
python/strands_shell/__init__.py:276-286class docstring never says a Shell must be used on its creating thread; the asyncio users this fixes will hitPanicException(aBaseException) fromasyncio.to_thread. Pre-existing. - Pre-existing, not filed (contrived input):
sleep 1e300panics insrc/commands/sleep.rs:21and the command reportsstatus=0, empty stderr. Say so if you'd like an issue.
| // SAFETY: `Shell` is `unsendable`, so PyO3 rejects calls from any other | ||
| // thread, and the `&mut self` borrow held across `detach` rules out re-entry | ||
| // on this thread while the GIL is released. |
There was a problem hiding this comment.
🟡 The SAFETY comment argues the wrong invariant. unsendable and the &mut self borrow are about re-entry/aliasing of Shell; neither is why asserting Send is OK. The actual reasons: Python::detach runs the closure synchronously on the calling thread (nothing crosses a thread), and the wrapped value holds no Python references — which is what PyO3's Send-as-Ungil bound is really guarding (its docs call smuggling a Bound through such a wrapper unsound). The unconditional impl<T> is also visible to the whole module, so a later thread::spawn(move || job.into_inner()) compiles with no comment saying it's UB.
Suggestion (optionally also move the struct + impls inside block_on_detached so misuse isn't expressible — that variant compiles and cargo fmt --check is clean):
| // SAFETY: `Shell` is `unsendable`, so PyO3 rejects calls from any other | |
| // thread, and the `&mut self` borrow held across `detach` rules out re-entry | |
| // on this thread while the GIL is released. | |
| // SAFETY: `Python::detach` runs its closure synchronously on the calling | |
| // thread, so the wrapped value never crosses a thread boundary. `T` must hold | |
| // no Python references (`Py`, `Bound`, `Python`); the `Send` bound on `detach` | |
| // is PyO3's stand-in for that check, and this impl bypasses it. |
There was a problem hiding this comment.
Fixed in 97b3df5. I used your SAFETY wording and also took the optional suggestion: SameThread and its impls now live inside block_on_detached, so the unsafe Send can't be reused elsewhere.
I checked the reasoning against pyo3 0.29.2 (the version in Cargo.lock). Python::detach calls f() directly under a SuspendAttach guard on the calling thread, and without the nightly feature Ungil is unsafe impl<T: Send> Ungil for T, so the Send bound is exactly the stand-in for "holds no Python references".
| /// Drives `fut` to completion with the GIL released, so other Python threads | ||
| /// and the asyncio event loop keep running while a command executes. |
There was a problem hiding this comment.
🟡 "the asyncio event loop keep[s] running" over-claims. It only holds when run is called off the loop thread. A coroutine calling shell.run() directly still stalls the loop, and the obvious escape hatch — await asyncio.to_thread(shell.run, …) with the Shell built on the loop thread — raises PanicException because the class is unsendable (verified on this build). The only working async pattern is a single-worker executor that both creates and uses the Shell, and nothing on the user-facing surface says so (python/strands_shell/__init__.py:359-361 only says "Run a command and capture its output").
| /// Drives `fut` to completion with the GIL released, so other Python threads | |
| /// and the asyncio event loop keep running while a command executes. | |
| /// Drives `fut` to completion with the GIL released, so other Python threads | |
| /// (and an asyncio loop on another thread) keep running while a command executes. |
Suggestion for the Python docstring at __init__.py:359: "Blocking; releases the GIL while the command runs. A Shell must be created and used on one thread, so from async code run it in a single-worker executor rather than asyncio.to_thread."
There was a problem hiding this comment.
Fixed in 97b3df5. The Rust doc now says "(and an asyncio loop on another thread)", and Shell.run has the docstring you suggested, with one addition from testing: the Shell must also be dropped on its worker thread. Otherwise pyo3 raises RuntimeError: ... unsendable, but is being dropped on another thread when it's garbage-collected from the loop thread.
I ran each case against a release build of this branch:
asyncio.to_thread(shell.run, ...)with the Shell built on the loop thread:PanicException, as you said.- Single-worker executor that creates, uses and drops the Shell: the command completes and the loop keeps ticking (~4,100 ticks during a ~4.5s command).
- Plain thread: a second Python thread made ~3,800 ticks while
run()was executing, so the GIL is released.
Address review on strands-agents#125: the Send impl is sound because Python::detach runs its closure on the calling thread and the future holds no Python references, not because of unsendable/&mut self. Move SameThread inside block_on_detached so it can't be misused elsewhere, narrow the asyncio claim, and document the single-thread requirement on Shell.run.
Address review on strands-agents#125: the Send impl is sound because Python::detach runs its closure on the calling thread and the future holds no Python references, not because of unsendable/&mut self. Move SameThread inside block_on_detached so it can't be misused elsewhere, narrow the asyncio claim, and document the single-thread requirement on Shell.run.
84f6fc0 to
97b3df5
Compare
|
@JackYPCOnline both review points from @strandly-the-agent are addressed in 97b3df5 (one commit on top of the original fix, docs/comments plus a scoping move, no behavior change). Details are in the two threads above. Checked locally against what
The CI and PR-title workflows show |
Description
The Python
Shellmethods drive the tokio runtime withself.runtime.block_on(...)while holding the GIL. So while a command runs, no other Python thread runs and an asyncio event loop stalls. The repro from #119 on 0.3.3: a background thread gets 1 tick duringrun("sleep 2"), against 1,588 during a plaintime.sleep(2).cli_mainin the same file already releases the GIL withpy.detach(...). The methods onShellcan't do the same directly, becausepy.detachrequires anUngil(Send) closure, and the closure captures two things that aren'tSend:shell::Shell, which holdsRc<Vec<NamedMcpClient>>. This is the same constraint the comment onWorkerinjs.rsdescribes.tokio::task::LocalSet.This PR adds one private helper,
block_on_detached, and routesrun,read_file,write_file,remove_fileandlist_filesthrough it:LocalSetis created inside the closure, so it never has to crossdetach.&mut shell::Shell, crossesdetachin a smallSameThreadwrapper withunsafe impl Send.Why the
unsafe implis sound.Python::detachruns the closure on the calling thread, so theRcnever actually changes thread.Shellis#[pyclass(unsendable)], so PyO3 rejects a call from any other thread, and the&mut selfborrow held acrossdetachrules out re-entry on the same thread. I checked the cross-thread case on both builds: a second thread callingrunon the sameShellwhile a command is running gets PyO3'sunsendablePanicException, the same as on 0.3.3.The alternative, if you'd rather have no
unsafe: mirror the Node binding and run the shell on a dedicated worker thread. That's a larger change toShell's structure, so I went with the smaller one first. Happy to switch.Related Issues
Fixes #119
Documentation PR
None needed. No public API changes.
Type of Change
Bug fix
Testing
How have you tested the change? Verify that the changes do not break functionality or introduce new warnings.
cargo test --workspace --all-targets,pytest tests/python,npm test)cargo fmtandcargo clippyDetails:
test_run_releases_the_gilcounts a background thread's ticks duringrun("sleep 0.5"). It fails on 0.3.3 (assert 1 > 50) and passes with this change; I ran it 5 times in a row with no failures.pytest tests/python: 45 passed (maturin develop --release, Python 3.14, macOS arm64).cargo test --workspace --all-targets: 1,749 passed, 0 failed.cargo fmt --checkis clean, andRUSTDOCFLAGS="-D warnings" cargo doc --no-deps --features pythonbuilds.cargo clippy --features python --libreports nothing insrc/python.rs; the lints it does report are in other files and already exist onmain.time.sleep(2). An asyncio task keeps ticking duringasyncio.to_thread(lambda: Shell().run("sleep 1")). Shell state (X=42, thenecho $X) persists across calls.write_file,read_file,list_filesandremove_filebehave as before.npm testnot run: the Node binding is untouched.Checklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.