openzim / openzim/python-libzim
Code review: writer-path use-after-free, sys.modules corruption, GIL held across all reader I/O
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 108
- Forks
- 29
- Avg merge
- 9d 1h
- Merged PRs (30d)
- 1
Description
AI-assisted review. Filed by agent driven by @soloturn via GDD.
Reviewed the Cython/C++ binding layer (libzim/libzim.pyx, libzim/libwrapper.h, libzim/libwrapper.cpp, libzim/zim.pxd) for performance, memory consumption, simplification, and error-proneness, weighted toward the first two.
Memory (highest severity)
libzim/libzim.pyx:111-123, contract at:284-291— use-after-free:WritingBlob's backing bytes are freed while libzim still holds thezim::Blob.blob_cy_call_fctbinds the returnedWritingBlobto a local, movesblob.c_blobout, and returns; Cython then decrefs the local.zim::Blob(const char*, size_type)is non-owning by design (per the code's own comment), so once the local dies, its backingbytesobject is freed and thezim::Blobhanded toContentProviderWrapper::feed()dangles. The base class works around this by stashingself._blob = next(...), butfeed()is a documented, overridable extension point, and the project's own test (tests/test_libzim_creator.py:785-789) does the unsafe thing directly (returnsBlob("1")without keeping a reference). Silent heap use-after-free on the writer's hottest path; likely to corrupt ZIM content non-deterministically under memory pressure with larger chunks. Fix: make the lifetime structural — haveContentProviderWrapperhold thePyObject*of the returned blob itself, not rely on the subclass convention.libzim/libzim.pyx:123→libzim/libwrapper.h:78,96-107— null-pointer dereference whenever a user'sfeed()raises. The exception path returnsmove(zim.Blob());wrapper::Blob()'s default constructor leavesmp_basenull, and the implicitoperator zim::Blob()dereferences it beforecallMethodOnObjeven checkserror. Any exception inside a user'sfeed()segfaults the interpreter instead of raisingRuntimeError(the equivalentget_sizefailure path is tested; this one isn't). Fix: givewrapper::Bloba valid empty state, or null-check in the conversion operator, and checkerrorbefore converting.libzim/libzim.pyx:119,232-238—WritingBlob.size()derefs null after the blob was consumed.return move(blob.c_blob)moves theunique_ptrout of the live Python object's member with no "moved-from" flag; a laterblob.size()call on the same (still valid from Python's view) object dereferences the null pointer and crashes. Fix: copy instead of move, or set a consumed flag and raise fromsize().libzim/libzim.pyx:912,947-957—Item.contentpermanently pins a whole decompressed cluster, with no release.self._blobis cached forever on first access; azim::Blobholds ashared_ptrto the entire decompressed cluster buffer, not just the item's slice. A consumer holding a list ofItems (common when walking an archive) pins one full cluster per item — megabytes each — completely bypassingset_cluster_cache_max_size. Looks like unbounded RSS growth/a leak in practice. Fix: drop the cache when the blob's view count returns to 0, or expose an explicit release.
Performance
- The entire reader path holds the GIL across blocking I/O and zstd decompression.
libzim/zim.pxd:119-183declaresexcept +but nonogilfor the reader API (getData,Archive()open,check(),search,getResults); the writer path already correctly releases the GIL at several sites (:516,539,564,587,592,599).Item.contentdecompresses up to a full cluster with the GIL held;Archive.check()checksums potentially GBs of I/O with the GIL held;Searcher.searchruns a Xapian query with the GIL held. Multi-threaded consumers (a threaded ZIM HTTP server, parallel readers) get zero parallelism and multi-hundred-ms GIL stalls freezing every unrelated thread. Fix: addnogilto the reader declarations (matching the writer's pattern) and wrap the heavy call sites inwith nogil; libzim'sArchiveis documented thread-safe for concurrent reads. libzim/libwrapper.cpp:34-42—import_libzim()runs unconditionally on everyObjWrapperconstruction (WriterItemWrapper,ContentProviderWrapper,IndexDataWrapper— 2-3 per item added), each doing a module import plus dict/signature lookups across ~11 exported API functions. For a large write job (mwoffliner/zimit adding millions of items) this is millions of redundant resolutions. Fix: hoist to a one-time static-guarded initialization.libzim/libzim.pyx:84-86,94-100,199—getattr(obj, method.decode('UTF-8'))allocates a fresh non-interned Python string per call, then does an uncachedgetattr. Per item added, libzim calls 6+ virtual methods on the user's object (get_path,get_title,get_mimetype,get_hints,get_contentprovider,get_indexdata) plus per-chunkget_size/feed— roughly 10+ transient allocations and un-interned lookups per entry, multiplied by millions of entries. Fix: pass method names as pre-internedPyObject*constants.libzim/libwrapper.cpp:224-235—getIndexDatamakes three separate Python round-trips per item (obj_has_attribute,method_is_none, then the actual call) to answer one question; two exist only to probe. Fix: a singlePyObject_GetAttrStringwith a branch on null/None/callable.libzim/libzim.pyx:195-205,188-193—hints_cy_call_fctbuilds an intermediate dict comprehension thatconvertToCppHintsthen re-iterates a second time; one alloc plus two full traversals per item added.libzim/libzim.pyx:1593→libzim/libwrapper.h:237-239— suggestion iteration heap-allocates and deep-copies (new Base(base)) a fullzim::SuggestionItem— including snippet computation, the expensive part — just to read one field (getPath()), then discards it.SearchResultSet.__iter__does this correctly by callinggetPath()directly without materializing the item.
Error-proneness
libzim/libzim.pyx:822-844,903-927,772-795(andSearch,SearchResultSet,SuggestionSearch,SuggestionResultSet) — every wrapper class is default-constructible from pure Python and segfaults on first use. None define__cinit__, soEntry()/Item()succeed with a nullmp_base; both classes are exported inreader_public_objects.Entry().title(ormemoryview(ReadingBlob())via__getbuffer__) crashes the interpreter with no traceback — reachable accidentally viacopy.copy/pickle/type(x)()patterns, not just deliberate misuse. Fix:__cinit__raisingTypeError, with internal factories bypassing it via__new__.libzim/zim.pxd:81-82,86-92,120,188,196-198,200-212— several C++ declarations are missingexcept +, inconsistently (the same class has it on one method but not its sibling — e.g.Entry::getPathat:121has it,getTitleat:120doesn't). Without it, a C++ exception unwinds unhandled out through the CPython eval loop and aborts the process instead of raising a Python exception.libzim/libzim.pyx:63-77—sys.modulesis poisoned with the wrong keys. The registration loop rebinds itsnameparameter, sosys.modules[name] = moduleat the end uses the last member's name, not the module's actual name — afterimport libzim,sys.modulescontains bogus entries likesys.modules["Searcher"] == <module libzim.search>andsys.modules["IndexData"] == <module libzim.writer>. Any unrelatedimport Searcherorimport IndexDataanywhere in the same process silently returns an unrelated libzim submodule; the intendedsys.modules["libzim.writer"]key is never set. Fix: use a distinct loop variable, register under the original name.libzim/libzim.pyx:482-495—add_illustrationhas three issues in one method: (a) declaresint sizewhile the underlying C++ signature takesunsigned int, soadd_illustration(-1, png)silently wraps to4294967295; (b) it's the onlyadd_*method missing theif not self._started: raise RuntimeError(...)guard every sibling has; (c) its C++ declaration isexcept + nogilbut the call site doesn't usewith nogil, unlike its siblings.libzim/libzim.pyx:597-601—Creator.__exit__hasif True or exc_type is None:— a disabled condition, sofinishZimCreation()runs unconditionally even when thewithblock raised. Awith Creator(...)block that dies mid-write still writes a complete-looking but silently-truncated ZIM. Also, iffinishZimCreationitself throws,self._started = False(meant to track state) is skipped since it isn't in afinally.
Minor (verified, lower impact)
libzim/libzim.pyx:797-799—ReadingBlob.__dealloc__raisingRuntimeError("Blob has views")is dead code:__getbuffer__increfsbuffer.obj, soview_count > 0implies a live reference and__dealloc__can't run while views exist; even if reached, an exception in__dealloc__is only printed, never propagated.libzim/libwrapper.cpp:50-55—ObjWrapper::operator=(ObjWrapper&&)overwritesm_objwithout decref'ing the old value — a reference leak, currently unused but a live footgun on a movable type.libzim/libzim.pyx:310-312—BaseWritingItem.__init__sets localget_indexdata = None(missingself.), a no-op; masked only becauseWriterItemWrapper::getIndexData's attribute-probe fallback handles the absence correctly anyway.libzim/libzim.pyx:1417(module docstring) — documentswith Archive(fpath) as zim:, butArchivedefines no__enter__/__exit__; copying the documented snippet raisesTypeError. README.md uses the correct non-context-manager form.libzim/libzim.pyx:1308-1319— theDeprecationWarningonget_illustration_sizespoints users toget_illustration_infos(), which doesn't exist anywhere in the codebase.libzim/libzim.pyx:1149—bytes(self.c_archive.getMetadata(...))is redundant; Cython already convertsstd::string→bytes.libzim/libzim.pyx:188-193vs:200—convertToCppHintsrequiresHintenum keys (raisingAttributeErroron raw ints) whilehints_cy_call_fctsilently filters out non-Hintkeys instead — two code paths for the same concept with opposite failure modes.libzim/libwrapper.h:232vs:233-235—FORWARD(bool, operator==)onSuggestionIteratorexpands to a call with no valid conversion (operator!=is hand-written specifically to work around this);zim.pxd:209declaresoperator==anyway, so using it from Cython would be a compile error.libzim/libzim.pyx:1011-1017—Archive.__eq__performsexpanduser().resolve()filesystem syscalls on every comparison; the type-check guard is also a roundabout spelling ofisinstance.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the highest-severity findings in libzim/libzim.pyx, libzim/libwrapper.h, libzim/libwrapper.cpp, and libzim/zim.pxd, then run the referenced tests/test_libzim_creator.py cases and inspect the missing error-path coverage. This review is done only when the selected findings have focused fixes and regression coverage; its many independent memory, exception, GIL, and registration issues make it unsuitable as one first contribution.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, python
- Domain
- backend, performance
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100