Skip to content

Commit 7029f99

Browse files
kounelisagisrwgk
andauthored
fix: segfault when moving scoped_ostream_redirect (#6033)
* fix: segfault when moving `scoped_ostream_redirect` 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. * fix: mark move constructor noexcept to satisfy clang-tidy * fix: use bool flag instead of nullptr sentinel for moved-from state 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. * fix: remove noexcept and propagate active flag from source - 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. * test: add unflushed ostream redirect regression 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 * fix: disarm moved-from pythonbuf after redirect move 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 --------- Co-authored-by: Ralf W. Grosse-Kunstleve <rgrossekunst@nvidia.com>
1 parent 2889136 commit 7029f99

File tree

3 files changed

+92
-3
lines changed

3 files changed

+92
-3
lines changed

include/pybind11/iostream.h

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,22 @@ class pythonbuf : public std::streambuf {
131131
setp(d_buffer.get(), d_buffer.get() + buf_size - 1);
132132
}
133133

134-
pythonbuf(pythonbuf &&) = default;
134+
pythonbuf(pythonbuf &&other) noexcept
135+
: buf_size(other.buf_size), d_buffer(std::move(other.d_buffer)),
136+
pywrite(std::move(other.pywrite)), pyflush(std::move(other.pyflush)) {
137+
const auto pending = (other.pbase() != nullptr && other.pptr() != nullptr)
138+
? static_cast<int>(other.pptr() - other.pbase())
139+
: 0;
140+
if (d_buffer != nullptr) {
141+
// Rebuild the put area from the transferred storage.
142+
setp(d_buffer.get(), d_buffer.get() + buf_size - 1);
143+
pbump(pending);
144+
} else {
145+
setp(nullptr, nullptr);
146+
}
147+
// Prevent the moved-from destructor from flushing through moved-out handles.
148+
other.setp(nullptr, nullptr);
149+
}
135150

136151
/// Sync before destroy
137152
~pythonbuf() override { _sync(); }
@@ -169,6 +184,7 @@ class scoped_ostream_redirect {
169184
std::streambuf *old;
170185
std::ostream &costream;
171186
detail::pythonbuf buffer;
187+
bool active = true;
172188

173189
public:
174190
explicit scoped_ostream_redirect(std::ostream &costream = std::cout,
@@ -178,10 +194,22 @@ class scoped_ostream_redirect {
178194
old = costream.rdbuf(&buffer);
179195
}
180196

181-
~scoped_ostream_redirect() { costream.rdbuf(old); }
197+
~scoped_ostream_redirect() {
198+
if (active) {
199+
costream.rdbuf(old);
200+
}
201+
}
182202

183203
scoped_ostream_redirect(const scoped_ostream_redirect &) = delete;
184-
scoped_ostream_redirect(scoped_ostream_redirect &&other) = default;
204+
// NOLINTNEXTLINE(performance-noexcept-move-constructor)
205+
scoped_ostream_redirect(scoped_ostream_redirect &&other)
206+
: old(other.old), costream(other.costream), buffer(std::move(other.buffer)),
207+
active(other.active) {
208+
if (active) {
209+
costream.rdbuf(&buffer); // Re-point stream to our buffer
210+
other.active = false;
211+
}
212+
}
185213
scoped_ostream_redirect &operator=(const scoped_ostream_redirect &) = delete;
186214
scoped_ostream_redirect &operator=(scoped_ostream_redirect &&) = delete;
187215
};

tests/test_iostream.cpp

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,40 @@ TEST_SUBMODULE(iostream, m) {
123123
.def("stop", &TestThread::stop)
124124
.def("join", &TestThread::join)
125125
.def("sleep", &TestThread::sleep);
126+
127+
m.def("move_redirect_output", [](const std::string &msg_before, const std::string &msg_after) {
128+
py::scoped_ostream_redirect redir1(std::cout, py::module_::import("sys").attr("stdout"));
129+
std::cout << msg_before << std::flush;
130+
py::scoped_ostream_redirect redir2(std::move(redir1));
131+
std::cout << msg_after << std::flush;
132+
});
133+
134+
m.def("move_redirect_output_unflushed",
135+
[](const std::string &msg_before, const std::string &msg_after) {
136+
py::scoped_ostream_redirect redir1(std::cout,
137+
py::module_::import("sys").attr("stdout"));
138+
std::cout << msg_before;
139+
py::scoped_ostream_redirect redir2(std::move(redir1));
140+
std::cout << msg_after << std::flush;
141+
});
142+
143+
// Redirect a stream whose original rdbuf is nullptr, then move the redirect.
144+
// Verifies that nullptr is correctly restored (not confused with a moved-from sentinel).
145+
m.def("move_redirect_null_rdbuf", [](const std::string &msg) {
146+
std::ostream os(nullptr);
147+
py::scoped_ostream_redirect redir1(os, py::module_::import("sys").attr("stdout"));
148+
os << msg << std::flush;
149+
py::scoped_ostream_redirect redir2(std::move(redir1));
150+
os << msg << std::flush;
151+
// After redir2 goes out of scope, os.rdbuf() should be restored to nullptr.
152+
});
153+
154+
m.def("get_null_rdbuf_restored", [](const std::string &msg) -> bool {
155+
std::ostream os(nullptr);
156+
{
157+
py::scoped_ostream_redirect redir(os, py::module_::import("sys").attr("stdout"));
158+
os << msg << std::flush;
159+
}
160+
return os.rdbuf() == nullptr;
161+
});
126162
}

tests/test_iostream.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,31 @@ def test_redirect_both(capfd):
284284
assert stream2.getvalue() == msg2
285285

286286

287+
def test_move_redirect(capsys):
288+
m.move_redirect_output("before_move", "after_move")
289+
stdout, stderr = capsys.readouterr()
290+
assert stdout == "before_moveafter_move"
291+
assert not stderr
292+
293+
294+
def test_move_redirect_unflushed(capsys):
295+
m.move_redirect_output_unflushed("before_move", "after_move")
296+
stdout, stderr = capsys.readouterr()
297+
assert stdout == "before_moveafter_move"
298+
assert not stderr
299+
300+
301+
def test_move_redirect_null_rdbuf(capsys):
302+
m.move_redirect_null_rdbuf("hello")
303+
stdout, stderr = capsys.readouterr()
304+
assert stdout == "hellohello"
305+
assert not stderr
306+
307+
308+
def test_null_rdbuf_restored():
309+
assert m.get_null_rdbuf_restored("test")
310+
311+
287312
@pytest.mark.skipif(sys.platform.startswith("emscripten"), reason="Requires threads")
288313
def test_threading():
289314
with m.ostream_redirect(stdout=True, stderr=False):

0 commit comments

Comments
 (0)