Code review: writer-path use-after-free, sys.modules corruption, GIL held across all reader I/O
まだ誰も着手していません。
評価
- 難易度
- 5/5
- 見積もり時間
- 1週間以上
- 初心者へのやさしさ
- 35/100
- issue の種類
- バグ
- 明瞭さ
- おおむね明確
- 活発さ
- 活発
- 領域
- backend, performance
調査の方向性
libzim/libzim.pyx、libzim/libwrapper.h、libzim/libwrapper.cpp、libzim/zim.pxd の中で重大度が最も高い指摘から始め、次に tests/test_libzim_creator.py の参照されたケースを実行し、不足しているエラーパスのカバレッジを調査します。このレビューは、選択した指摘に対象を絞った修正とリグレッションカバレッジが用意されて初めて完了します。メモリ、例外、GIL、登録に関する独立した問題が多数あるため、1つの最初のコントリビューションとして扱うのは適していません。
索引モデルが issue の本文から書いたものです。
説明
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.
- 主要言語
- Python
- スター
- 109
- フォーク
- 29
- 平均マージ
- 9日 1時間
- マージ済み PR(30日)
- 1
コントリビューションガイド
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
openzim/python-libzim のほかの issue
-
enhancement
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
openzim/python-libzim#268 · コメント 4 件 · リアクション 1 件 ·
-
Release 3.14.0 オープンtask
openzim/python-libzim#267 · 担当者 1 名 ·
-
Build for ABI3 オープンenhancement
難易度 4/5 3〜5日 初心者へのやさしさ 48/100
openzim/python-libzim#264 · コメント 1 件 ·
-
enhancement upstream
難易度 4/5 3〜5日 初心者へのやさしさ 25/100
openzim/python-libzim#241 ·
-
enhancement
難易度 5/5 1週間以上 初心者へのやさしさ 30/100
openzim/python-libzim#240 ·
openzim/python-libzim の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
anthropics/skills#1811 · コメント 1 件 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
speaches-ai/speaches#678 ·
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
datalayer/mcp-compose#42 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
conda-forge/spacy-feedstock#177 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
UKGovernmentBEIS/inspect_evals#2523 ·