Skip to content

Persistent node layer can hand back an attribute QName with a corrupted prefix on a second read within one request #6781

Closed

Description

@duncdrum

Summary

While fixing #3417 (XSLTServlet corrupting xml:-prefixed attributes), I traced the root cause to a deeper, still-unidentified bug in eXist's persistent node layer: within a single HTTP request, a second SAX serialization of the same stored document/attribute can come back with a corrupted attribute prefix, even though the attribute's namespace URI and local name stay correct.

The fix landed in #3417's PR works around this defensively (see below), but the actual corruption mechanism was not found and could plausibly affect other code paths that serialize the same document twice within one request.

Reproduction context

Using the exact scenario from #3417 (a controller.xql forwarding a .xml request to XSLTServlet, with an identity-transform stylesheet), instrumenting ReceiverToSAX.startElement showed the following sequence for a single HTTP request against /db/apps/test3417/test.xml (<test><strange xml:id="foo"/></test>):

DEBUG attr uri=[http://www.w3.org/XML/1998/namespace] local=[id] qName=[xml:id] prefix=[xml]   <- first serialization: correct
...                                                                                             <- stylesheet compiled in between (xsl:stylesheet, xsl:mode)
DEBUG attr uri=[http://www.w3.org/XML/1998/namespace] local=[id] qName=[xm:id]  prefix=[xm]    <- second serialization: corrupted

The first serialization of test.xml's xml:id attribute is correct (prefix=xml). The second serialization of the same document, after the XSLT stylesheet is compiled in between, comes back with the prefix truncated to xm.

I instrumented AttrImpl.deserialize() and AttrImpl.addToList() (the two places that decode an attribute's prefix from raw persistent storage bytes and are the only callers of SymbolTable.getQName(...)) and confirmed neither is invoked for the corrupted second read — no matching debug line appears for it at all. So the corrupted AttrImpl/QName is coming from some node-object reuse/caching path that skips raw byte deserialization entirely, not from a byte-decoding bug (QName itself is immutable — all fields are final — so a shared static instance being mutated is also ruled out).

What's already ruled out

  • QNamePool (org.exist.dom.persistent.QNamePool): its hash/equals keys include the prefix, so a lookup for ("id", XML_NS, "xml") cannot return a cached ("id", XML_NS, "xm") entry through normal hash-collision handling.
  • Namespaces.XML_ID_QNAME and friends: these are QName, which is immutable (localPart/namespaceURI/prefix are all final), so no aliasing mutation is possible.
  • AttrImpl.deserialize() / AttrImpl.addToList(): instrumented directly; not invoked for the corrupted read.

Suspected area

Given the corrupted read skips both known deserialization entry points, the likely culprit is some node-object cache/pool that hands back an already-materialized AttrImpl (or its containing element's attribute list) without re-deserializing -- e.g. org.exist.util.pool.NodePool, or a DOMFile node buffer cache -- combined with something that constructs a fresh, wrongly-prefixed QName and installs it via NamedNode.setQName()/setNodeName() on what turns out to be a shared/reused object, in a way that isn't visible from the two known deserialize entry points.

Workaround shipped in #3417's fix

Saxon12CompatSAXFilter.startElement() now unconditionally rewrites any attribute whose namespace URI is http://www.w3.org/XML/1998/namespace to use the canonical xml: prefix, regardless of what prefix arrives -- this is safe per the XML namespaces spec (that binding can never legally be anything else) and fully resolves the externally-visible bug, but it papers over the root cause rather than fixing it.

Suggested next steps

  • Reproduce with a smaller, targeted Java unit test that serializes the same stored document twice within one broker session/transaction, with something else (e.g. compiling an unrelated XSLT stylesheet, or storing/reading a second document) happening in between, to isolate the exact interleaving that triggers it.
  • Audit NodePool (exist-core/src/main/java/org/exist/util/pool/NodePool.java) and NativeSerializer's node iteration path for object-pooling/reuse bugs where a released node is handed back to a new caller while still referenced elsewhere.
  • Check whether this can corrupt attribute prefixes on any namespace (not just the reserved xml one), since the workaround above only guards the one namespace where the correct prefix is statically known.

Environment

  • develop branch, commit c0b5b3ab0b0b7971cb958bd991c4ebefc08fcee8
  • macOS, Java 21 (Zulu/Temurin), Saxon-HE 12.5

Activity

  1. duncdrum commented on Sep 28, 2026

    @duncdrum
    ContributorAuthor

    Follow-up on isolating a minimal reproduction for a pending test: I tried four different hypotheses for the mechanism, none of which reproduced the corruption outside the full live HTTP request through XSLTServlet:

    1. Direct double-serialize via Serializer.toSAX(DocumentImpl), with an XSLT stylesheet compiled in between (mirroring the original debug trace's timing) — came back correct (xml:id) both times.
    2. Text round-trip: Serializer.serialize(DocumentImpl) to a string, then re-parsed with a plain (non-pooled) SAXParserFactory parser — came back correct.
    3. Generic forward → view pipeline without XSLTServlet (a model.xql returning the document, a view step's echo.xql reading it back via request:get-data()) — came back correct, suggesting this isn't a general URL-rewrite pipeline issue.
    4. Pooled XMLReader round-trip: Serializer.serialize(...) to text, then re-parsed via BrokerPool.getParserPool().borrowXMLReader()/returnXMLReader() (the exact mechanism XSLTServlet.doPost uses for reader.parse(new InputSource(stream))), repeated 4x with a stylesheet compile in between — still came back correct every time.

    So the trigger requires something specific to the actual live HTTP request path through a real Jetty container that none of these narrower reproductions capture -- possibly something about the real HttpServletRequest.getInputStream() (vs. a StringReader), the GZIPInputStream detection/fallback logic in XSLTServlet.doPost, CachingResponseWrapper/XQueryURLRewrite's response buffering between the model and view steps, or genuine request-level state I'm not replicating with ExistEmbeddedServer.

    Given the difficulty isolating this, I did not add a @Ignored unit test to the codebase for this issue, since anything I could get to compile either didn't reproduce (misleading) or would need to attach directly to the live HTTP path, which is significantly more machinery than a "pending" test typically carries. Whoever picks this up will likely need to instrument the real HTTP servlet path directly (e.g. via ExistWebServer, with temporary debug logging like the original trace in the issue description) rather than trying to shrink it to a broker-level unit test first.

  2. added
    bugissue confirmed as bug
    investigateissues being looked at
    XSLTIssues arising from handing over to or retrieving values from Saxon get this label.
    on Sep 28, 2026
  3. added theissue type on Sep 28, 2026
  4. moved this to Backlog in v7.0.0on Sep 28, 2026
  5. added this to the eXist-7.0.0 milestone on Sep 28, 2026
  6. wylovan commented on Oct 4, 2026

    @wylovan

    I stumbled across an eXist bug, and Claude has written the below comment that suggests that this bug is also related...

    I think the cause is QNamePool.insert(), and that the "ruled out" note above holds for get() but not for insert(). Fix and test in #6795.

    The mechanism

    SymbolTable.getQName() calls namePool.get(...), and on a miss calls namePool.add(...) → insert(...):

    • get() compares namespace, local name, prefix and name type.
    • insert() detects duplicates with QName.equals(QName), which compares only namespace and local name.

    So when xml:id is requested and the pool already holds xm:id:

    1. get() misses, because the prefixes differ.
    2. insert() probes from the xml:id slot. If the probe sequence runs through xm:id's slot, QName.equals calls it a duplicate and returns it.
    3. The caller gets back xm:id, and keeps getting it on every later request for that name, until the pool is cleared or its layout changes.

    The two names never hash to the same slot, so this needs a well-filled pool, where another collision starts a probe sequence that passes through. That would explain why the corrupted read skipped AttrImpl.deserialize(), and why the smaller reproductions above came back correct.

    Reproduced

    QNamePoolTest in the PR fills the default pool with 380 pairs of names that differ only in prefix or type. Against the current insert() it fails three ways:

    • prefix of attribute 'name213' expected:<xml> but was:<xm>: this issue's symptom
    • name type of element 'name336' expected:<0> but was:<1>
    • name type of attribute 'name374' expected:<1> but was:<0>

    The name-type case is how we hit it, on a development instance and in CI: an element manifest was stored and indexed as the attribute manifest. Name tests on it then found nothing, with no error raised:

    let $s := xmldb:store($c, "probe.xml", <r><manifest/></r>)
    return (
      count(doc($s)//manifest),                          (: 0 :)
      count(doc($s)//@manifest),                         (: 1, although the document has no attributes :)
      count(doc($s)/r/*[local-name() = 'manifest'])      (: 1 :)
    )

    Fix

    insert() now uses the same full comparison as get(). With it, all four QNamePoolTest tests pass, as do the org.exist.dom.persistent tests, SymbolTableTest, XPathQueryTest and XQueryTest. We also built a full distribution from the branch, restored our development database into it, and our application's test suites pass on it.

    I haven't run #3417's XSLT reproduction against the fix. If the xm:id corruption is gone with it, the Saxon12CompatSAXFilter workaround could probably go too.

  7. duncdrum commented on Oct 5, 2026

    @duncdrum
    ContributorAuthor

    A data point from a different workload, in case it is useful for the discussion of #6795.

    While making the XQSuite test files of the Lucene module run in parallel (33 files against one database), we see wrong reads of persistent nodes that look like this issue's symptom but are rarer and different in detail: a query over one document returns text that belongs to another test file's document, a child-axis test is true for an element that has no children, a hit's node resolves to nothing (NullPointerException in RecordPos.getPage()), or ft:field returns null for one document, and the wrong data then stays for the rest of that file. It never happens in serial runs, which fits a shared structure whose contents depend on the order in which names arrive, so QNamePool looked like a good candidate.

    I tried it: the two-line change of #6795 on top of our branch, 40 runs of the suite at 8 files at a time. 33 of the 40 runs were fully green. Earlier runs of the same suite without it had a failure not involving the statistics tests in at least 28 percent of the runs at that setting (9 of 32, collected over several probes with slightly different branches), against 17.5 percent here (7 of 40). With these numbers that is not a difference I can claim, and the same failures still occurred (the cascade in one facets file, nested-element tests in another, a reindex count). So #6795 did not remove what we see; that does not say anything against #6795 itself, which fixes a real defect that the unit test in the PR shows.

    I could not reproduce our wrong reads outside the suite either: two stress tests (3 writer threads creating, filling and removing collections so that document ids are freed and reused, 3 readers re-reading fixed documents; once without an index, once with a Lucene index on every collection) ran 41049 and 285 reads with no difference. If anyone sees this issue's corruption together with concurrent writes to other collections, a suite like ours (the Lucene XQSuite files with parallel execution switched on) might help as a reproducer once the branch is merged; the branch is #6796.

    [Commented by Duncan, drafted by Claude Code.]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    XSLTIssues arising from handing over to or retrieving values from Saxon get this label.bugissue confirmed as buginvestigateissues being looked at

    Type

    Projects

    • Status
      Done

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions