fix: segfault when moving scoped_ostream_redirect#6033
fix: segfault when moving scoped_ostream_redirect#6033kounelisagis wants to merge 3 commits intopybind:masterfrom
scoped_ostream_redirect#6033Conversation
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 |
Description
The default move constructor of
scoped_ostream_redirectcaused a segfault when the moved-to object (or the original stream) was subsequently used.The root cause:
= defaultmoved the internalpythonbuf(transferring itsd_buffer,pywrite,pyflushand nulling the streambuf base pointers), but left the stream (e.g.std::cout) still pointing at the moved-from buffer. Any subsequent write through the stream calledoverflow()on the zombiepythonbuf, which dereferencedpptr()(now null) — immediate segfault.Reproducer
Fix
Replace
= defaultwith an explicit move constructor that:costream.rdbuf(&buffer))other.old = nullptrto disarm the moved-from destructorThe destructor is guarded with
if (old)so the moved-from object skips restoring the stream.Suggested changelog entry:
scoped_ostream_redirect: the default move constructor left the stream pointing at a moved-frompythonbufwith null internal pointers, causing a null dereference on the next write.