fix: segfault when moving scoped_ostream_redirect#6033
Conversation
The default move constructor left the stream (`std::cout`) pointing at the moved-from `pythonbuf`, whose internal buffer and streambuf pointers were nulled by the move. Any subsequent write through the stream dereferenced null, causing a segfault. Replace `= default` with an explicit move constructor that re-points the stream to the new buffer and disarms the moved-from destructor.
2b243d9 to
156332c
Compare
|
@kounelisagis Please see below. I'm using Cursor GPT-5.4 Extra High Fast. I could easily ask it to first add the a test for the Initial Review Prompt
Findings
explicit scoped_ostream_redirect(std::ostream &costream = std::cout,
const object &pyostream
= module_::import("sys").attr("stdout"))
: costream(costream), buffer(pyostream) {
old = costream.rdbuf(&buffer);
}
~scoped_ostream_redirect() {
if (old) {
costream.rdbuf(old);
}
}
scoped_ostream_redirect(const scoped_ostream_redirect &) = delete;
scoped_ostream_redirect(scoped_ostream_redirect &&other) noexcept
: old(other.old), costream(other.costream), buffer(std::move(other.buffer)) {
other.old = nullptr; // Disarm moved-from destructor
costream.rdbuf(&buffer); // Re-point stream to our buffer
}The old destructor restored
m.def("move_redirect_output", [](const std::string &msg) {
py::scoped_ostream_redirect redir1(std::cout, py::module_::import("sys").attr("stdout"));
std::cout << "before" << std::flush;
py::scoped_ostream_redirect redir2(std::move(redir1));
std::cout << msg << std::flush;
});def test_move_redirect(capsys):
m.move_redirect_output("after")
stdout, stderr = capsys.readouterr()
assert stdout == "beforeafter"
assert not stderrOpen Questions
OverallI would not merge this as-is because of the Aside from that, the main idea of the fix looks right: rebinding the stream to the moved-to |
Using `old == nullptr` as the moved-from sentinel was incorrect because nullptr is a valid original rdbuf() value (e.g. `std::ostream os(nullptr)`). Replace with an explicit `active` flag so the destructor correctly restores nullptr buffers. Add tests for the nullptr-rdbuf edge case.
|
@kounelisagis please see below. This is still using Cursor GPT-5.4 Extra High Fast. What do you think about the "I would be more comfortable with something like" code? Maybe clang-tidy doesn't demand the Prompt
ResponseFindings
scoped_ostream_redirect(scoped_ostream_redirect &&other) noexcept
: old(other.old), costream(other.costream), buffer(std::move(other.buffer)) {
other.active = false; // Disarm moved-from destructor
costream.rdbuf(&buffer); // Re-point stream to our buffer
}What That Means
There are two separate concerns: First, /// Move constructor; steals the object from ``other`` and preserves its reference count
object(object &&other) noexcept : handle(other) { other.m_ptr = nullptr; }but Second, So my answer to your direct question is:
Active Flag ConcernYes, you do still have to worry about internal consistency of Right now the move constructor assumes every source object should produce an active destination object. That is fine for the first move from a live redirect, but not for moving an already inactive object. The ownership bit should really move too. I would be more comfortable with something like: scoped_ostream_redirect(scoped_ostream_redirect &&other)
: old(other.old),
costream(other.costream),
buffer(std::move(other.buffer)),
active(other.active) {
if (active) {
costream.rdbuf(&buffer);
other.active = false;
}
}RecommendationI would remove If clang-tidy is the only reason for it, I would rather suppress that warning than promise a stronger exception guarantee than the actual member operations and standard-library calls provide. I would also initialize UncertaintyThe part I’m least certain about is whether |
- Remove noexcept: pythonbuf inherits from std::streambuf whose move is not guaranteed nothrow on all implementations. Suppress clang-tidy with NOLINTNEXTLINE instead. - Initialize active from other.active so that moving an already moved-from object does not incorrectly re-activate the redirect. - Only rebind the stream and disarm the source when active.
Cover the buffered-before-move case for `scoped_ostream_redirect`, which still crashes despite the current move fix. This gives the PR a direct reproducer for the remaining bug path. Made-with: Cursor
|
Cursor GPT-5.4 Extra High Fast found another bug :-( This time around, I asked it to go ahead and add the test. I'm running the CI to see if/where it reproduces. I'll ask it to generate the fix as well. For completeness: This was my entire prompt: Instead of a thumbs up as hoped it gave me this: |
The redirect guard now survives moves, but buffered output could still remain in the moved-from `pythonbuf` and be flushed during destruction through moved-out Python handles. Rebuild the destination put area from the transferred storage and clear the source put area so unflushed bytes follow the active redirect instead of crashing in the moved-from destructor. Made-with: Cursor
|
I have a fix already, but I'll hold off pushing it until the currently running CI is complete (so that we have a complete before/after comparison). I'm pasting the Cursor-generated explanation for the fix here now: I tracked the remaining crash to a different layer than The earlier changes fixed the guard-level ownership problem: after a move, only one object restores the original The underlying issue was that That is why the fix belongs in The new |
|
Cursor-generated analysis of CI results before/after commit e84bd70: I dug through the unpacked logs from the two before-fix runs, 24480860578 and 24480860438, and the two after-fix runs, 24484831320 and 24484831234, to compare behavior before vs. after Before the fix, the new regression test had very high signal. In every non-skipped environment I checked, The only slightly surprising part was that the failure mode was not identical everywhere:
The pre-fix green jobs are not real counterexamples:
After e84bd70, I do not see the old failure signature anymore. In the after-fix runs, the jobs that previously crashed now explicitly show So my read is:
|
Cursor GPT-5.4 Extra High Fast generated:
Summary
Fix the move semantics of
py::scoped_ostream_redirect.Moving a redirect has to transfer two kinds of state together:
rdbuf()detail::pythonbufThe destination object must become the only active redirect, the stream must be re-pointed at the destination buffer, and any already-buffered bytes must move with it. If any of that state stays behind in the moved-from object, later writes or destruction can still touch stale moved-from state and crash.
This PR makes that transfer explicit and internally consistent.
What changes
scoped_ostream_redirectnow uses an explicit move constructor that rebinds the stream to the destinationpythonbuf.activeflag, so only the live redirect restores the originalrdbuf()in its destructor.activereplaces the oldnullptr-sentinel style of representing moved-from state, which also allows a realnullptroriginalrdbuf()to be handled correctly.detail::pythonbufnow has an explicit move constructor that rebuilds the destination put area from the transferred storage and clears the source put area.Tests
Add regression coverage for:
rdbuf()isnullptrnullptrrdbuf()after destructionTogether these tests cover the final move contract: stream rebinding, moved-from destructor disarming, preservation of pending buffered data, and correct handling of
nullptras a legitimate original stream buffer.Suggested changelog entry
scoped_ostream_redirectmove semantics so moved redirects preserve buffered output, restore the original stream buffer exactly once, and correctly handle streams whose originalrdbuf()isnullptr.