Skip to content

Fix the UniversalDetector uchardet_t handle lifecycle - #70

Merged
wbarnha merged 4 commits into
masterfrom
claude/universaldetector-handle-lifecycle
Aug 10, 2026
Merged

Fix the UniversalDetector uchardet_t handle lifecycle#70
wbarnha merged 4 commits into
masterfrom
claude/universaldetector-handle-lifecycle

Conversation

@wbarnha

@wbarnha wbarnha commented Aug 5, 2026

Copy link
Copy Markdown
Member

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_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, since reading result finalizes detection on its own precisely so callers can stop without closing.

build 20,000 detectors dropped without close()
before RSS +380 MB (19,458 B/detector)
this PR RSS +0 B

Four inseparable parts

  1. __dealloc__ releases the handle.
  2. close() and feed()'s error path clear _ud after uchardet_delete(). This is not optional hygienetp_dealloc still 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.
  3. 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 doesn't call super().__init__() — had _ud NULL and dereferenced it on the first feed().
  4. 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.

Why __init__ still does work

An 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 next feed() onto the previous stream and report a bogus mixed-encoding label (KOI8-R where a fresh detector says WINDOWS-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 throws std::bad_alloc rather than returning NULL — which means uchardet's own out-of-memory checks are dead code:

mCharSetProbers[0] = new nsMBCSGroupProber(mLanguageFilter);
if (nsnull == mCharSetProbers[0])
  return NS_ERROR_OUT_OF_MEMORY;      /* never taken */

The exception then unwound out of the extension into CPython's C frames. That is undefined behaviour, and in practice std::terminate():

terminate called after throwing an instance of 'std::bad_alloc'
  what():  std::bad_alloc

Four entry points reach a new: uchardet_new() (new HandleUniversalDetector), uchardet_handle_data() (the group probers), uchardet_reset() (nsMBCSGroupProber's code-point buffers) and uchardet_data_end() (reporting candidates into a std::vector). All four are now declared except +, so Cython catches and translates to MemoryError. 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 a finally

detect_with_confidence() gets a try/finally here for one reason: assigning uchardet_get_encoding() to a bytes is a PyBytes_FromString, which can raise MemoryError and jump straight to Cython's error label, skipping the uchardet_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 being cdef void does not make those safe: since Cython 3 a void cdef function propagates exceptions via a PyErr_Occurred() check at the call site.

/* generated from master's _cchardet.pyx; vtable dispatch elided */
...->_finalize(__pyx_v_self); if (unlikely(PyErr_Occurred())) __PYX_ERR(0, 150, __pyx_L4_error)

That check jumps past both the uchardet_delete() and the self._closed = 1 underneath it, so a failed close() was simply undone — the detector stayed open, still holding its handle, and reading result afterwards silently re-finalized the stream and answered as if close() 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:

lever reaches used for
RLIMIT_AS + mmap the address space away uchardet's C++ new the except + translation
_testcapi.set_nomemory() PyMem_* / PyObject_* only the PyBytes_FromString inside close()

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, so close() simply succeeds. set_nomemory() is precise and leaves C++ new alone, so the failure lands exactly where the bug lives.

Each fix was verified by reverting only itself:

reverted result
except + the allocation test fails 3/3, rc=-6, std::bad_alloc in stderr
close()'s finally detector reports open after a failed close(); 3,200 held detectors leak ~19 KB each, vs ~87 B with the fix

That second number is the point of holding them: reporting "closed" only shows _closed was set, not that uchardet_delete() ran. Nothing is dropped, so __dealloc__ never runs and close() 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 data from 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 the std::bad_alloc signature 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_maxrss is 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 _testcapi tests 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-uchardet jobs. 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 with mypy clean on the rebased tree.

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
wbarnha force-pushed the claude/universaldetector-handle-lifecycle branch from 5f63dc1 to a0417e5 Compare August 8, 2026 14:33
@wbarnha
wbarnha changed the base branch from claude/cchardet-issue-55-8fyc69 to master August 8, 2026 14:34
@wbarnha wbarnha closed this Aug 8, 2026
@wbarnha wbarnha reopened this Aug 8, 2026
claude added 3 commits August 9, 2026 22:42
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.
@wbarnha
wbarnha merged commit ed6369d into master Aug 10, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants