Skip to content

Free input provider callbacks on cleanup#167

Open
shunkica wants to merge 6 commits into
jameslan:masterfrom
shunkica:fix/input-provider-table-leak
Open

Free input provider callbacks on cleanup#167
shunkica wants to merge 6 commits into
jameslan:masterfrom
shunkica:fix/input-provider-table-leak

Conversation

@shunkica

Copy link
Copy Markdown
Contributor

Closes #166

Stacked PR. Based on #165

Fix

xmlRegisterInputProvider adds four wasm function-table entries per call, one per
callback closure (match/open/read/close), and xmlCleanupInputProvider
reclaimed none of them. addFunction cannot reuse an entry until removeFunction
releases it, so every register grew the table by four permanently and pinned the
provider, and any buffers it captured, for the process lifetime.

  • binding/exported-runtime-functions.txt exports removeFunction
    (-s ALLOW_TABLE_GROWTH=1 is already set, so no other emcc change is needed).
  • src/libxml2.mts tracks the entries of each registration and releases them in
    cleanup. libxml2 keeps owning the callback stack and its precedence, and the
    provider's file descriptors still pass straight through.

shunkica added 6 commits July 24, 2026 11:32
XmlDocument.fromString/fromBuffer rejected well-formed documents whenever
libxml2 emitted any diagnostic, including warning-level ones, and leaked the
parsed C document on that throw.

Two defects in parse() (src/document.mts):

1. Failure was decided by presence of any diagnostic (errDetails.length > 0),
   never by severity, so warnings (XML_ERR_WARNING = 1) were treated as fatal.
   Now failure is gated on level >= XML_ERR_ERROR (2), so warnings pass and
   error/fatal diagnostics (incl. XML_PARSE_DTDVALID validity errors) still throw.

2. The pre-throw cleanup guard was inverted (`if (!xml) xmlFreeDoc(xml)`): it
   freed only when the pointer was null and skipped freeing a real document,
   which then leaked since the throw precedes wrapper/finalizer creation. The
   guard is corrected to free the produced document before throwing.

To make surviving warnings observable (they were silently discarded), capture
the diagnostic severity and expose non-fatal diagnostics on the parsed document:

- XmlErrorStruct.level (xmlError offset 12) + ErrorDetail.level
- XmlDocument.warnings: ErrorDetail[] (level-1 diagnostics; [] for clean parses)

Existing details assertions updated for the new level field; adds tests for
warning acceptance, warning surfacing, validity-error rejection, and no-leak.
match() used a prototype-chain lookup (this._data[filename] != null), so a
document referencing a name like 'toString' matched Object.prototype members.
libxml2 then called open() with a non-buffer value, and read() later threw
'TypeError: data.slice is not a function' instead of failing gracefully.

Use an own-property check plus Uint8Array validation via a shared buffer()
helper; open() now returns number | undefined (matching XmlInputProvider and
the fs provider's no-match signal). Add es2022.object lib so Object.hasOwn
type-checks (emit target stays es2021).
)

xmlXPathCompiledEval returns NULL on a runtime evaluation failure (undefined
namespace prefix, variable, or function). compiledXPathEval returned it
unchecked, so get()/eval() dereferenced the null pointer and threw the generic
XmlError: 'Access with null pointer' — indistinguishable from an internal bug.

Reset the last error before evaluation and, when the result is NULL, throw
XmlXPathError forwarding libxml2's own diagnostic, e.g.:
  Failed to evaluate XPath expression '//x:y': Undefined namespace prefix: x

Uses the already-exported xmlGetLastError/xmlResetLastError; no build change.
…chars

Closes jameslan#164.

xmlSaveToIO/xmlOutputBufferCreateIO leaked an outputHandlerStorage slot
whenever libxml2 returned NULL (e.g. an unsupported encoding), because
the close callback that frees the slot never fired. XmlDocument.save/
XmlElement.save also silently produced empty output in that case. The
allocate/free-on-null lifecycle is now centralized in a withOutputHandler
helper, and xmlSaveToIO throws XmlError naming the unsupported encoding
instead of returning NULL.

XmlStringOutputBufferHandler decoded each write() as a standalone buffer,
corrupting multibyte characters split across chunks. It now decodes with
{ stream: true } and flushes in close().
xmlRegisterInputProvider adds four wasm function-table entries per call,
one per callback closure, and xmlCleanupInputProvider reclaimed none of
them. addFunction cannot reuse an entry until removeFunction releases it,
so every register grew the table by four permanently and pinned the
provider and its buffers for the process lifetime.

Export Emscripten's removeFunction, track the entries of each
registration and release them in cleanup. A registration that libxml2
refuses now frees its entries as well, instead of stranding them.
@github-actions

Copy link
Copy Markdown

Coverage after merging fix/input-provider-table-leak into master will be

100.00%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   c14n.mts100%100%100%100%
   diag.mts99.51%100%90.91%100%
   disposable.mts100%100%100%100%
   disposeShim.mts100%100%100%100%
   document.mts100%100%100%100%
   dtd.mts100%100%100%100%
   index.mts100%100%100%100%
   libxml2.mts99.89%99.14%100%100%647
   metadataShim.mts100%100%100%100%
   nodejs.mts100%100%100%100%
   nodes.mts99.92%99.22%100%100%76
   utils.mts100%100%100%100%
   validates.mts100%100%100%100%
   xpath.mts100%100%100%100%

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.

xmlRegisterInputProvider leaks wasm function-table entries on every register/cleanup cycle

1 participant