From ebf6fa2aee9c4a428f28fa098a0d190573058c8f Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Sat, 1 Aug 2026 09:37:45 +0000 Subject: [PATCH 1/7] gh-154997: Guard stale raw access after reentrant detach --- Lib/test/test_io/test_bufferedio.py | 218 ++++++++++++++++++++++++++++ Modules/_io/bufferedio.c | 87 +++++++++-- 2 files changed, 296 insertions(+), 9 deletions(-) diff --git a/Lib/test/test_io/test_bufferedio.py b/Lib/test/test_io/test_bufferedio.py index e83dd0d4e28d006..82d1020b5476587 100644 --- a/Lib/test/test_io/test_bufferedio.py +++ b/Lib/test/test_io/test_bufferedio.py @@ -623,6 +623,75 @@ def test_bad_readinto_type(self): bufio.readline() self.assertIsInstance(cm.exception.__cause__, TypeError) + def test_reentrant_detach_during_read_all(self): + # gh-154997: Reentrant detach() during read_all() should not crash. + # Use a duck-typed raw stream so _bufferedreader_read_all() + # dispatches through raw.read(). Return EOF to terminate the loop. + class Duck: + closed = False + + def __init__(self): + self.n = 0 + + def readable(self): + return True + + def writable(self): + return False + + def seekable(self): + return False + + def close(self): + pass + + def flush(self): + pass + + def read(self, *args): + self.n += 1 + if self.n == 1: + self.buf.detach() + return b"abc" if self.n < 3 else b"" + + def readinto(self, b): + data = self.read() + b[0:len(data)] = data + return len(data) + + raw = Duck() + buf = self.tp(raw) + raw.buf = buf + + with self.assertRaisesRegex(ValueError, "detached"): + buf.read() + + def test_reentrant_detach_during_raw_read(self): + # gh-154997: Reentrant detach() during read() should not crash. + # detach() fires from inside raw.readinto(), which + # _bufferedreader_raw_read() calls directly. + class Raw(io.RawIOBase): + def __init__(self): + super().__init__() + self.fired = False + + def readable(self): + return True + + def readinto(self, b): + if not self.fired: + self.fired = True + self.buf.detach() + b[0:1] = b"a" + return 1 + + raw = Raw() + buf = self.tp(raw, buffer_size=4) + raw.buf = buf + + with self.assertRaisesRegex(ValueError, "detached"): + buf.read(64) + @unittest.skipUnless(sys.maxsize > 2**32, 'requires 64bit platform') @unittest.skipIf(check_sanitizer(thread=True), 'ThreadSanitizer aborts on huge allocations (exit code 66).') @@ -1002,6 +1071,155 @@ def closed(self): self.assertRaisesRegex(ValueError, "test", bufio.flush) self.assertRaisesRegex(ValueError, "test", bufio.close) + def test_reentrant_detach_during_close(self): + # gh-154997: Reentrant detach() during close() should not crash. + + class B(self.tp): + armed = True + + def flush(self): + if self.armed: + self.armed = False + super().detach() + + buf = B(self.BytesIO()) + with self.assertRaisesRegex(ValueError, "detached"): + buf.close() + + def test_reentrant_detach_during_raw_write(self): + # gh-154997: Reentrant detach() during write() should not crash. + # Use a small buffer and partial writes so write() reaches + # _bufferedwriter_raw_write(). Override flush() to avoid + # re-entering the buffered lock during detach(). + class B(self.tp): + def flush(self): + return None + + class Raw(io.RawIOBase): + def __init__(self): + super().__init__() + self.fired = False + + def writable(self): + return True + + def write(self, b): + if not self.fired: + self.fired = True + self.buf.detach() + return 1 # partial write -> flush loop iterates again + + raw = Raw() + buf = B(raw, buffer_size=4) + raw.buf = buf + + with self.assertRaisesRegex(ValueError, "detached"): + buf.write(b"0123456789abcdef") + + def test_reentrant_detach_during_truncate(self): + # gh-154997: Reentrant detach() during truncate() should not crash. + # Avoid the seek path and make flush() a no-op so detach() + # exercises the guarded truncate path without re-entering + # the buffered lock. + class B(self.tp): + def flush(self): + return None + + class Raw(io.RawIOBase): + def __init__(self): + super().__init__() + self.fired = False + + def readable(self): + return False + + def writable(self): + return True + + def seekable(self): + return True + + def tell(self): + return 0 + + def seek(self, pos, whence=0): + return 0 + + def truncate(self, pos=None): + return 0 + + def write(self, b): + if not self.fired: + self.fired = True + self.buf.detach() + return len(b) + + raw = Raw() + buf = B(raw, buffer_size=64) + raw.buf = buf + + buf.write(b"012") + with self.assertRaisesRegex(ValueError, "detached"): + buf.truncate(1) + + def test_reentrant_detach_during_raw_tell(self): + # gh-154997: After detach(), truncate() calls _buffered_raw_tell() + # to refresh the cached position. That ValueError is intentionally + # swallowed, so verify the guarded path by checking raw.tell() is + # not called again after detach(). + class B(self.tp): + def flush(self): + return None + + class Raw(io.RawIOBase): + def __init__(self): + super().__init__() + self.fired = False + self.tell_calls = 0 + + def readable(self): + return False + + def writable(self): + return True + + def seekable(self): + return True + + def tell(self): + self.tell_calls += 1 + return 0 + + def seek(self, pos, whence=0): + return 0 + + def write(self, b): + return len(b) + + def truncate(self, pos=None): + if not self.fired: + self.fired = True + self.buf.detach() + return 0 + + raw = Raw() + buf = B(raw, buffer_size=64) + raw.buf = buf + + # _buffered_init() calls _buffered_raw_tell() once at construction. + self.assertEqual(raw.tell_calls, 1) + + buf.write(b"012") + # Must not crash; the swallowed ValueError means truncate() itself + # still reports success, unchanged from pre-detach behavior. + self.assertEqual(buf.truncate(1), 0) + + # The critical assertion: raw_access_safe() short-circuited the + # post-truncate tell() call -- tell_calls stayed at 1, it did NOT + # increment to 2. Before the fix this dispatched through a NULL + # self->raw and crashed with SIGSEGV. + self.assertEqual(raw.tell_calls, 1) + class PyBufferedWriterTest(BufferedWriterTest, PyTestCase): tp = pyio.BufferedWriter diff --git a/Modules/_io/bufferedio.c b/Modules/_io/bufferedio.c index 5537947f6a51c11..b6c1e76f61df2d0 100644 --- a/Modules/_io/bufferedio.c +++ b/Modules/_io/bufferedio.c @@ -481,6 +481,28 @@ _io._Buffered._dealloc_warn / [clinic start generated code]*/ +static PyObject * +raw_access_safe(buffered *self) +{ + /* Similar to textio.c's buffer_access_safe(), but preserves + _Buffered's detached and uninitialized error semantics. */ + if (self->raw == NULL) { + if (self->detached) { + PyErr_SetString(PyExc_ValueError, + "raw stream has been detached"); + } + else { + PyErr_SetString(PyExc_ValueError, + "I/O operation on uninitialized object"); + } + return NULL; + } + + /* Returning a borrowed reference is safe since BufferedIO methods are + protected by critical sections. */ + _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(self); + return self->raw; +} static PyObject * _io__Buffered__dealloc_warn_impl(buffered *self, PyObject *source) @@ -588,7 +610,12 @@ _io__Buffered_close_impl(buffered *self) exc = PyErr_GetRaisedException(); } - res = PyObject_CallMethodNoArgs(self->raw, &_Py_ID(close)); + PyObject *raw = raw_access_safe(self); + if (raw == NULL) { + goto end; + } + + res = PyObject_CallMethodNoArgs(raw, &_Py_ID(close)); if (self->buffer) { PyMem_Free(self->buffer); @@ -785,16 +812,23 @@ _buffered_raw_tell(buffered *self) { Py_off_t n; PyObject *res; - res = PyObject_CallMethodNoArgs(self->raw, &_Py_ID(tell)); + PyObject *raw = raw_access_safe(self); + if (raw == NULL) { + return -1; + } + + res = PyObject_CallMethodNoArgs(raw, &_Py_ID(tell)); if (res == NULL) return -1; + n = PyNumber_AsOff_t(res, PyExc_ValueError); Py_DECREF(res); if (n < 0) { - if (!PyErr_Occurred()) + if (!PyErr_Occurred()) { PyErr_Format(PyExc_OSError, "Raw stream returned invalid position %" PY_PRIdOFF, (PY_OFF_T_COMPAT)n); + } return -1; } self->abs_pos = n; @@ -804,32 +838,45 @@ _buffered_raw_tell(buffered *self) static Py_off_t _buffered_raw_seek(buffered *self, Py_off_t target, int whence) { + PyObject *raw; PyObject *res, *posobj, *whenceobj; Py_off_t n; + raw = raw_access_safe(self); + if (raw == NULL) { + return -1; + } + posobj = PyLong_FromOff_t(target); if (posobj == NULL) return -1; + whenceobj = PyLong_FromLong(whence); if (whenceobj == NULL) { Py_DECREF(posobj); return -1; } - res = PyObject_CallMethodObjArgs(self->raw, &_Py_ID(seek), + + res = PyObject_CallMethodObjArgs(raw, &_Py_ID(seek), posobj, whenceobj, NULL); Py_DECREF(posobj); Py_DECREF(whenceobj); + if (res == NULL) return -1; + n = PyNumber_AsOff_t(res, PyExc_ValueError); Py_DECREF(res); + if (n < 0) { - if (!PyErr_Occurred()) + if (!PyErr_Occurred()) { PyErr_Format(PyExc_OSError, "Raw stream returned invalid position %" PY_PRIdOFF, (PY_OFF_T_COMPAT)n); + } return -1; } + self->abs_pos = n; return n; } @@ -1482,7 +1529,12 @@ _io__Buffered_truncate_impl(buffered *self, PyTypeObject *cls, PyObject *pos) } Py_CLEAR(res); - res = PyObject_CallMethodOneArg(self->raw, &_Py_ID(truncate), pos); + PyObject *raw = raw_access_safe(self); + if (raw == NULL) { + goto end; + } + + res = PyObject_CallMethodOneArg(raw, &_Py_ID(truncate), pos); if (res == NULL) goto end; /* Reset cached position */ @@ -1636,8 +1688,14 @@ _bufferedreader_raw_read(buffered *self, char *start, Py_ssize_t len) We then retry reading, ignoring the signal if no handler has raised (see issue #10956). */ + PyObject *raw = raw_access_safe(self); + if (raw == NULL) { + Py_DECREF(memobj); + return -1; + } + do { - res = PyObject_CallMethodOneArg(self->raw, &_Py_ID(readinto), memobj); + res = PyObject_CallMethodOneArg(raw, &_Py_ID(readinto), memobj); } while (res == NULL && _PyIO_trap_eintr()); Py_DECREF(memobj); if (res == NULL) @@ -1745,7 +1803,12 @@ _bufferedreader_read_all(buffered *self) } /* Read until EOF or until read() would block. */ - data = PyObject_CallMethodNoArgs(self->raw, &_Py_ID(read)); + PyObject *raw = raw_access_safe(self); + if (raw == NULL) { + goto cleanup; + } + + data = PyObject_CallMethodNoArgs(raw, &_Py_ID(read)); if (data == NULL) goto cleanup; if (data != Py_None && !PyBytes_Check(data)) { @@ -1991,9 +2054,15 @@ _bufferedwriter_raw_write(buffered *self, char *start, Py_ssize_t len) We then retry writing, ignoring the signal if no handler has raised (see issue #10956). */ + PyObject *raw = raw_access_safe(self); + if (raw == NULL) { + Py_DECREF(memobj); + return -1; + } + do { errno = 0; - res = PyObject_CallMethodOneArg(self->raw, &_Py_ID(write), memobj); + res = PyObject_CallMethodOneArg(raw, &_Py_ID(write), memobj); errnum = errno; } while (res == NULL && _PyIO_trap_eintr()); Py_DECREF(memobj); From fe6b7066f9175be40bfd553733a5854925cd63fd Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Sat, 1 Aug 2026 09:52:47 +0000 Subject: [PATCH 2/7] gh-154997: Add NEWS entry --- .../2026-08-01-09-52-23.gh-issue-154997.wcYpao.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-08-01-09-52-23.gh-issue-154997.wcYpao.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-01-09-52-23.gh-issue-154997.wcYpao.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-01-09-52-23.gh-issue-154997.wcYpao.rst new file mode 100644 index 000000000000000..61c74a8d27af25e --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-01-09-52-23.gh-issue-154997.wcYpao.rst @@ -0,0 +1,3 @@ +Fixed a crash in :mod:`io` buffered streams when ``detach()`` is called +reentrantly during buffered operations. The affected operations now raise +``ValueError`` instead of dereferencing a stale raw stream pointer. From dff7edea4dab7ba2c858d6ab564660b55996731f Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Sat, 1 Aug 2026 10:15:01 +0000 Subject: [PATCH 3/7] gh-154997: Declare raw_access_safe before use --- Modules/_io/bufferedio.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Modules/_io/bufferedio.c b/Modules/_io/bufferedio.c index b6c1e76f61df2d0..4893a5a17c1994a 100644 --- a/Modules/_io/bufferedio.c +++ b/Modules/_io/bufferedio.c @@ -755,8 +755,12 @@ _io__Buffered_isatty_impl(buffered *self) } /* Forward decls */ +static PyObject * +raw_access_safe(buffered *self); + static PyObject * _bufferedwriter_flush_unlocked(buffered *); + static Py_ssize_t _bufferedreader_fill_buffer(buffered *self); static void From 559722f712f47f1755fab7a568f3a631100225b8 Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Sat, 1 Aug 2026 10:21:20 +0000 Subject: [PATCH 4/7] gh-154997: Remove redundant raw_access_safe declaration --- Modules/_io/bufferedio.c | 3 --- 1 file changed, 3 deletions(-) diff --git a/Modules/_io/bufferedio.c b/Modules/_io/bufferedio.c index 4893a5a17c1994a..d1da107e905087d 100644 --- a/Modules/_io/bufferedio.c +++ b/Modules/_io/bufferedio.c @@ -755,9 +755,6 @@ _io__Buffered_isatty_impl(buffered *self) } /* Forward decls */ -static PyObject * -raw_access_safe(buffered *self); - static PyObject * _bufferedwriter_flush_unlocked(buffered *); From 406f674413c8c0f79d04a6bbd255cec4d3d50a23 Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Sat, 1 Aug 2026 10:33:06 +0000 Subject: [PATCH 5/7] gh-154997: Move raw_access_safe outside Clinic block --- Modules/_io/bufferedio.c | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Modules/_io/bufferedio.c b/Modules/_io/bufferedio.c index d1da107e905087d..1541608a5108b3f 100644 --- a/Modules/_io/bufferedio.c +++ b/Modules/_io/bufferedio.c @@ -471,16 +471,6 @@ buffered_traverse(PyObject *op, visitproc visit, void *arg) return 0; } -/* Because this can call arbitrary code, it shouldn't be called when - the refcount is 0 (that is, not directly from tp_dealloc unless - the refcount has been temporarily re-incremented). */ -/*[clinic input] -_io._Buffered._dealloc_warn - - source: object - / - -[clinic start generated code]*/ static PyObject * raw_access_safe(buffered *self) { @@ -504,6 +494,16 @@ raw_access_safe(buffered *self) return self->raw; } +/* Because this can call arbitrary code, it shouldn't be called when + the refcount is 0 (that is, not directly from tp_dealloc unless + the refcount has been temporarily re-incremented). */ +/*[clinic input] +_io._Buffered._dealloc_warn + + source: object + / + +[clinic start generated code]*/ static PyObject * _io__Buffered__dealloc_warn_impl(buffered *self, PyObject *source) /*[clinic end generated code: output=d8db21c6dec0e614 input=8f845f2a4786391c]*/ From b49ff0bb44c4e8bbb3678443fc4fa8676749e4e6 Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Sat, 1 Aug 2026 10:45:46 +0000 Subject: [PATCH 6/7] gh-154997: Regenerate Argument Clinic output --- Modules/_io/bufferedio.c | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/_io/bufferedio.c b/Modules/_io/bufferedio.c index 1541608a5108b3f..9f3821bf94e2c4e 100644 --- a/Modules/_io/bufferedio.c +++ b/Modules/_io/bufferedio.c @@ -504,6 +504,7 @@ _io._Buffered._dealloc_warn / [clinic start generated code]*/ + static PyObject * _io__Buffered__dealloc_warn_impl(buffered *self, PyObject *source) /*[clinic end generated code: output=d8db21c6dec0e614 input=8f845f2a4786391c]*/ From 7f2600d87f0a1235907970f6546ac9c51a5fcc1d Mon Sep 17 00:00:00 2001 From: Bhuvansh Kataria Date: Sat, 1 Aug 2026 11:49:40 +0000 Subject: [PATCH 7/7] gh-154997: Protect buffered raw access from reentrant detach --- Lib/test/test_io/test_bufferedio.py | 54 +++++++++++++++++++ ...-08-01-09-52-23.gh-issue-154997.wcYpao.rst | 0 Modules/_io/bufferedio.c | 53 ++++++++++-------- 3 files changed, 86 insertions(+), 21 deletions(-) rename Misc/NEWS.d/next/{Core_and_Builtins => Library}/2026-08-01-09-52-23.gh-issue-154997.wcYpao.rst (100%) diff --git a/Lib/test/test_io/test_bufferedio.py b/Lib/test/test_io/test_bufferedio.py index 82d1020b5476587..3b3b16f0da7d46e 100644 --- a/Lib/test/test_io/test_bufferedio.py +++ b/Lib/test/test_io/test_bufferedio.py @@ -1220,6 +1220,60 @@ def truncate(self, pos=None): # self->raw and crashed with SIGSEGV. self.assertEqual(raw.tell_calls, 1) + def test_reentrant_detach_during_raw_seek(self): + # gh-154997: After detach(), seek() calls _buffered_raw_seek(). + # Verify the guarded path by checking raw.seek() is not called + # again after detach(). + + class B(self.tp): + def flush(self): + return None + + class Raw(io.RawIOBase): + def __init__(self): + super().__init__() + self.fired = False + self.seek_calls = 0 + + def readable(self): + return False + + def writable(self): + return True + + def seekable(self): + return True + + def tell(self): + return 0 + + def seek(self, pos, whence=0): + self.seek_calls += 1 + return 0 + + def write(self, b): + return len(b) + + def truncate(self, pos=None): + if not self.fired: + self.fired = True + self.buf.detach() + return 0 + + raw = Raw() + buf = B(raw, buffer_size=64) + raw.buf = buf + + # _buffered_init() performs one seek() during initialization. + initial_seek_calls = raw.seek_calls + + buf.write(b"012") + self.assertEqual(buf.truncate(1), 0) + + # _buffered_raw_seek() should not dispatch through a detached raw + # object, so no additional seek() should have occurred. + self.assertEqual(raw.seek_calls, initial_seek_calls) + class PyBufferedWriterTest(BufferedWriterTest, PyTestCase): tp = pyio.BufferedWriter diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-01-09-52-23.gh-issue-154997.wcYpao.rst b/Misc/NEWS.d/next/Library/2026-08-01-09-52-23.gh-issue-154997.wcYpao.rst similarity index 100% rename from Misc/NEWS.d/next/Core_and_Builtins/2026-08-01-09-52-23.gh-issue-154997.wcYpao.rst rename to Misc/NEWS.d/next/Library/2026-08-01-09-52-23.gh-issue-154997.wcYpao.rst diff --git a/Modules/_io/bufferedio.c b/Modules/_io/bufferedio.c index 9f3821bf94e2c4e..42b54d245819437 100644 --- a/Modules/_io/bufferedio.c +++ b/Modules/_io/bufferedio.c @@ -475,7 +475,16 @@ static PyObject * raw_access_safe(buffered *self) { /* Similar to textio.c's buffer_access_safe(), but preserves - _Buffered's detached and uninitialized error semantics. */ + _Buffered's detached and uninitialized error semantics. + + Unlike buffer_access_safe(), we do not assert that a critical + section is held. This helper protects against same-thread + reentrant detach(), and it is also used during __init__, + where no critical section is held. + + Return a new reference so callers safely hold the raw object + across callbacks that may detach it. + */ if (self->raw == NULL) { if (self->detached) { PyErr_SetString(PyExc_ValueError, @@ -488,10 +497,7 @@ raw_access_safe(buffered *self) return NULL; } - /* Returning a borrowed reference is safe since BufferedIO methods are - protected by critical sections. */ - _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(self); - return self->raw; + return Py_NewRef(self->raw); } /* Because this can call arbitrary code, it shouldn't be called when @@ -612,12 +618,11 @@ _io__Buffered_close_impl(buffered *self) } PyObject *raw = raw_access_safe(self); - if (raw == NULL) { - goto end; + if (raw != NULL) { + res = PyObject_CallMethodNoArgs(raw, &_Py_ID(close)); + Py_DECREF(raw); } - res = PyObject_CallMethodNoArgs(raw, &_Py_ID(close)); - if (self->buffer) { PyMem_Free(self->buffer); self->buffer = NULL; @@ -820,6 +825,7 @@ _buffered_raw_tell(buffered *self) } res = PyObject_CallMethodNoArgs(raw, &_Py_ID(tell)); + Py_DECREF(raw); if (res == NULL) return -1; @@ -861,6 +867,7 @@ _buffered_raw_seek(buffered *self, Py_off_t target, int whence) res = PyObject_CallMethodObjArgs(raw, &_Py_ID(seek), posobj, whenceobj, NULL); + Py_DECREF(raw); Py_DECREF(posobj); Py_DECREF(whenceobj); @@ -1537,6 +1544,7 @@ _io__Buffered_truncate_impl(buffered *self, PyTypeObject *cls, PyObject *pos) } res = PyObject_CallMethodOneArg(raw, &_Py_ID(truncate), pos); + Py_DECREF(raw); if (res == NULL) goto end; /* Reset cached position */ @@ -1690,14 +1698,15 @@ _bufferedreader_raw_read(buffered *self, char *start, Py_ssize_t len) We then retry reading, ignoring the signal if no handler has raised (see issue #10956). */ - PyObject *raw = raw_access_safe(self); - if (raw == NULL) { - Py_DECREF(memobj); - return -1; - } - do { + PyObject *raw = raw_access_safe(self); + if (raw == NULL) { + Py_DECREF(memobj); + return -1; + } + res = PyObject_CallMethodOneArg(raw, &_Py_ID(readinto), memobj); + Py_DECREF(raw); } while (res == NULL && _PyIO_trap_eintr()); Py_DECREF(memobj); if (res == NULL) @@ -1811,6 +1820,7 @@ _bufferedreader_read_all(buffered *self) } data = PyObject_CallMethodNoArgs(raw, &_Py_ID(read)); + Py_DECREF(raw); if (data == NULL) goto cleanup; if (data != Py_None && !PyBytes_Check(data)) { @@ -2056,16 +2066,17 @@ _bufferedwriter_raw_write(buffered *self, char *start, Py_ssize_t len) We then retry writing, ignoring the signal if no handler has raised (see issue #10956). */ - PyObject *raw = raw_access_safe(self); - if (raw == NULL) { - Py_DECREF(memobj); - return -1; - } - do { + PyObject *raw = raw_access_safe(self); + if (raw == NULL) { + Py_DECREF(memobj); + return -1; + } + errno = 0; res = PyObject_CallMethodOneArg(raw, &_Py_ID(write), memobj); errnum = errno; + Py_DECREF(raw); } while (res == NULL && _PyIO_trap_eintr()); Py_DECREF(memobj); if (res == NULL)