Fix the UniversalDetector uchardet_t handle lifecycle - #70
Merged
Conversation
The uchardet_t handle was only ever released by an explicit close(). There is no __dealloc__, so a detector that was simply dropped leaked it -- and dropping is the documented pattern, because reading `result` finalizes detection on its own precisely so callers can stop without closing. Measured at 19,458 bytes per detector: 20,000 dropped detectors grow RSS by 380 MB on the current branch head, and by 0 with this change. The fix is four inseparable parts: - __dealloc__ releases the handle. - close() and feed()'s error path clear _ud after uchardet_delete(). This is not optional hygiene: tp_dealloc still runs for the object afterwards, so __dealloc__ WITHOUT these assignments turns every explicitly closed detector into a double free. Verified -- a build with __dealloc__ and no NULLing segfaults (rc=139) on the first close-then- drop, where the full change exits 0. - Allocation moves from __init__ to __cinit__, which runs exactly once and cannot be re-entered from Python. This also fixes a pre-existing segfault: a detector built without running __init__ -- via __new__, or a subclass that does not call super().__init__() -- had _ud NULL and dereferenced it on the first feed(). - Every remaining uchardet_* call site is guarded on _ud. Operating on a released detector stays a silent no-op rather than becoming an error: close() has to remain idempotent, and feed()/reset() were already no-ops once _closed was set. __init__ deliberately still resets the stream rather than becoming empty. Making it a no-op would have been a silent wrong-answer regression -- d.__init__() on a live detector would concatenate the next feed() onto the previous stream and report a bogus mixed-encoding label instead of starting fresh. It resets the live handle and allocates a new one only when the previous was closed, so re-init cannot leak either. Separately, detect_with_confidence() now uses try/finally. Assigning uchardet_get_encoding() to a `bytes` is a PyBytes_FromString that can raise MemoryError and jump to Cython's error label, skipping the uchardet_delete() underneath it. A failed uchardet_new() now raises MemoryError rather than being dereferenced. src/tests/test_lifecycle.py covers all of it. Against the unfixed build the suite segfaults on the __new__ case and fails the leak assertion; the re-init tests pass there by construction and exist to keep the behaviour from regressing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TWqKLkfwd8fPxhU4KjXUVB
wbarnha
force-pushed
the
claude/universaldetector-handle-lifecycle
branch
from
August 8, 2026 14:33
5f63dc1 to
a0417e5
Compare
Follow-up to the handle-lifecycle work: two remaining out-of-memory gaps, both found by review of that change. uchardet is C++ and allocates with plain `new`, which throws std::bad_alloc rather than returning NULL -- uchardet_new() is `new HandleUniversalDetector`, HandleData() news the group probers, Reset() news nsMBCSGroupProber's code-point buffers, DataEnd() reports candidates into a std::vector. uchardet's own `if (nsnull == ...) return NS_ERROR_OUT_OF_MEMORY` checks are therefore dead code, and the NULL checks on this side never fire either. The exception instead unwound out of the extension into CPython's C frames, which is undefined behaviour and observably std::terminate(): the new test aborts with SIGABRT on an unpatched build, reproducibly. Declaring the allocating entry points `except +` makes Cython translate it to MemoryError. uchardet_delete() is left alone -- it runs a destructor, and it is called from __dealloc__ where nothing could be propagated. The NULL checks stay for implementations that do return NULL. close() now releases the handle in a `finally`. _finalize() can raise -- uchardet_data_end() is now `except +`, and _read_candidate() assigns uchardet_get_encoding() to a `bytes`, a PyBytes_FromString that can raise MemoryError. Being `cdef void` does not swallow that: since Cython 3 those propagate via a PyErr_Occurred() check at the call site, and the generated code jumped straight past the uchardet_delete(), leaving an explicit close() that released nothing with _closed unset. This mirrors the try/finally already used in detect_with_confidence(). The new test pins the allocation-failure path: it caps RLIMIT_AS, mmaps the remaining address space away and holds detectors until uchardet's `new` has to reach the OS. It is Linux-only, runs in a subprocess, and skips rather than fails if an allocator will not be squeezed, so the only way it reports failure is the crash it exists to catch. The close() path has no test: reaching it needs a Python-level allocation failure inside finalization, and under this kind of pressure data_end()'s small allocations are still served from the free list. It was verified by inspecting the generated C++ instead.
The close() half of the previous commit shipped without a test: real memory pressure could not reach it, because the C++ allocations inside uchardet_data_end() are small and keep being served from the heap free list long after the address space is exhausted, so close() simply succeeded. _testcapi.set_nomemory() is the right lever instead. It fails PyMem_*/PyObject_* precisely and on demand while leaving C++ `new` alone, so the failure lands exactly where the bug lives: the PyBytes_FromString in _read_candidate(). The two injection techniques are not interchangeable -- RLIMIT_AS reaches uchardet's `new` and nothing else, set_nomemory() reaches the Python allocator and nothing else -- so each test uses the one that reaches its bug. Two tests, because the obvious assertion is only a proxy. That a failed close() leaves the detector reporting "closed" shows _closed was set, not that uchardet_delete() ran. So the second test holds every detector whose close() raised: nothing is dropped, __dealloc__ never runs, and close() is the only thing that can have released a handle. Without the finally that leaks ~19 KB per detector, the same signature as the missing __dealloc__; with it, ~87 B, which is just the PyObject wrappers. Verified by reverting only the finally, leaving `except +` in place: both tests fail, the other twelve pass. Both skip where _testcapi is absent (PyPy, stripped builds); the RSS one is Linux-only for the usual ru_maxrss reason.
It went red on 3.10, 3.13 and 3.14 with "cannot allocate memory for thread-local data: ABORT" and an empty verdict. That is the dynamic loader dying under the squeeze, not uchardet: starving a process this hard makes it fragile in ways unrelated to what is being tested, and the test reported that as a failure. Two changes. The verdict is now written to a file the instant the MemoryError is caught -- still under pressure, using only an fd and a bytes object prepared beforehand -- instead of being printed at the end, where cleanup or interpreter shutdown dying first would erase it. And only the actual bug signature (std::bad_alloc / "terminate called" in stderr) counts as failure; anything else means the experiment did not run on this runner, which is a skip. The test still catches the bug it exists for: with `except +` reverted it fails 3/3 with rc=-6. With the fix it passes 5/5, and the CI failure mode now lands on the skip path rather than the assert.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #55 / #68. Now rebased onto
master— #68 has merged, so this gets the full CI matrix.These are all pre-existing bugs, not free-threading ones. They landed after #68 because every guard added here is a check-then-act on
_ud, which is only atomic on a free-threaded build thanks to the critical sections #68 introduced.The leak
The
uchardet_thandle was only ever released by an explicitclose(). There is no__dealloc__, so a detector that was simply dropped leaked it — and dropping is the documented pattern, since readingresultfinalizes detection on its own precisely so callers can stop without closing.close()Four inseparable parts
__dealloc__releases the handle.close()andfeed()'s error path clear_udafteruchardet_delete(). This is not optional hygiene —tp_deallocstill runs for the object afterwards, so__dealloc__without these assignments makes every explicitly closed detector a double free. Verified: a build with__dealloc__and no NULLing segfaults (rc=139) on the first close-then-drop, where the full change exits 0.__init__to__cinit__, which runs exactly once and cannot be re-entered from Python. This also fixes a pre-existing segfault: a detector built without running__init__— via__new__, or a subclass that doesn't callsuper().__init__()— had_udNULL and dereferenced it on the firstfeed().uchardet_*call site is guarded on_ud. Operating on a released detector stays a silent no-op rather than becoming an error:close()has to remain idempotent, andfeed()/reset()were already no-ops once_closedwas set.Why
__init__still does workAn earlier draft made
__init__empty, since__cinit__now owns allocation. That was a silent wrong-answer regression:d.__init__()on a live detector would concatenate the nextfeed()onto the previous stream and report a bogus mixed-encoding label (KOI8-Rwhere a fresh detector saysWINDOWS-1251) with no exception and no warning.So
__init__resets the live handle instead, and allocates a new one only when the previous was closed — preserving the original semantics without reintroducing the leak. All three re-init paths (live, finalized, closed) return byte-identical results to a plain fresh detector.Out of memory, uchardet took the interpreter down with it
uchardet is C++ and allocates with plain
new, so allocation failure throwsstd::bad_allocrather than returning NULL — which means uchardet's own out-of-memory checks are dead code:The exception then unwound out of the extension into CPython's C frames. That is undefined behaviour, and in practice
std::terminate():Four entry points reach a
new:uchardet_new()(new HandleUniversalDetector),uchardet_handle_data()(the group probers),uchardet_reset()(nsMBCSGroupProber's code-point buffers) anduchardet_data_end()(reporting candidates into astd::vector). All four are now declaredexcept +, so Cython catches and translates toMemoryError.uchardet_delete()deliberately is not: it runs a destructor, and it is called from__dealloc__where nothing could be propagated anyway. The NULL checks stay for any implementation that really does return NULL, including a system libuchardet built with-fno-exceptions.close()had to release the handle in afinallydetect_with_confidence()gets atry/finallyhere for one reason: assigninguchardet_get_encoding()to abytesis aPyBytes_FromString, which can raiseMemoryErrorand jump straight to Cython's error label, skipping theuchardet_delete()underneath it. (Before this PR that function deleted the handle separately at each exit, so the error label leaked it.)close()reaches the same conversion through_finalize()→_read_candidate(), and beingcdef voiddoes not make those safe: since Cython 3 a voidcdeffunction propagates exceptions via aPyErr_Occurred()check at the call site.That check jumps past both the
uchardet_delete()and theself._closed = 1underneath it, so a failedclose()was simply undone — the detector stayed open, still holding its handle, and readingresultafterwards silently re-finalized the stream and answered as ifclose()had never been called.Tests
src/tests/test_lifecycle.py, 14 tests. Against the unfixed build the file segfaults on the__new__case and fails the leak assertion; the two re-init tests pass there by construction and exist to keep that behaviour from regressing.The out-of-memory paths need the failure injected, and the two levers are not interchangeable:
RLIMIT_AS+ mmap the address space awaynewexcept +translation_testcapi.set_nomemory()PyMem_*/PyObject_*onlyPyBytes_FromStringinsideclose()Real memory pressure cannot reach the
close()bug:uchardet_data_end()'s allocations are small and keep being served from the heap free list long after the address space is exhausted, soclose()simply succeeds.set_nomemory()is precise and leaves C++newalone, so the failure lands exactly where the bug lives.Each fix was verified by reverting only itself:
except +rc=-6,std::bad_allocin stderrclose()'sfinallyclose(); 3,200 held detectors leak ~19 KB each, vs ~87 B with the fixThat second number is the point of holding them: reporting "closed" only shows
_closedwas set, not thatuchardet_delete()ran. Nothing is dropped, so__dealloc__never runs andclose()is the only thing that can have released a handle.The allocation-failure test is deliberately biased towards skipping. Starving a process that hard makes it fragile in ways that have nothing to do with uchardet — it first went red on 3.10/3.13/3.14 with
cannot allocate memory for thread-local datafrom the dynamic loader — so the verdict is written to a file the instant it is known rather than printed at the end where a later death would erase it, and only thestd::bad_allocsignature counts as a failure. Anything else means the experiment did not run on that runner.Both RSS tests run in a subprocess and are Linux-only —
ru_maxrssis KB on Linux but bytes on macOS, and the leak is platform-independent, so there's no reason to encode that quirk into a CI gate. The two_testcapitests skip where that module is absent (PyPy, stripped builds).153 collected; 151 passed, 2 skipped on every interpreter in the matrix — 3.10, 3.11, 3.12, 3.13, 3.13t, 3.14, 3.14t — plus both
-Dsystem-uchardetjobs. The allocation-failure test is the one that varies: it ran on 3.10 and skipped on 3.13t in the same run, which is the intended behaviour rather than a flake._cchardet.pyi(added by #71) stays accurate:__cinit__/__dealloc__are not part of the type surface and no public signature changed. Verified withmypyclean on the rebased tree.