[bugfix] Fix premature closing of still-referenced binary values#6506
Conversation
289b228 to
33d7cde
Compare
|
@joewiz can you rebase this please. Should clear out ci flake |
line-o
left a comment
There was a problem hiding this comment.
LGTM, but I assume we are waiting for the follow up.
BinaryValueFromFile did not honor the shared-reference reference counting
that XQueryContext.enterEnclosedExpr()/exitEnclosedExpr() rely on:
incrementSharedReferences() was a no-op and close() released the FileChannel
unconditionally. So when a file-backed binary value was used in an element
(or document) constructor -- an enclosed expression -- exitEnclosedExpr()
closed the value's channel immediately after the constructor, and any later
read of the value failed with "Underlying channel has been closed". For
example (count($w) forces the constructor to be evaluated before $b is read
again):
let $b := file:read-binary($path)
let $w := <a>{$b}</a>
return (count($w), $b)[2]
BinaryValueFromInputStream was unaffected because it already reference-counts
through AbstractFilterInputStreamCache (close() releases only when the shared
reference count reaches zero).
Root cause confirmed by instrumentation: the close fires from
XQueryContext.exitEnclosedExpr() right after the constructor reads the value;
the failing read is the subsequent use of the value. It reproduces on both
the embedded and REST execution paths -- only the consumer of the second read
differs (the caller vs the response serializer).
Fix: give BinaryValueFromFile the same reference counting -- a counter
starting at 1, incremented by incrementSharedReferences() and decremented by
close(), releasing the channel/file handle only when it reaches zero. Eager
cleanup of values not shared out of a scope is preserved.
Tests:
- BinaryValueFromFileSharedReferenceTest (unit): models
enterEnclosedExpr()/exitEnclosedExpr() on a shared file-backed value; it
must stay open and readable, then be released by the final close.
- AbstractBinariesTest#readBinaryUsedInElementConstructorThenReadAgain
(file module): the query above, run over BOTH the embedded
(EmbeddedBinariesTest) and REST (RestBinariesTest) execution paths; both
fail with "Underlying channel has been closed" before the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
33d7cde to
a9ca1e5
Compare
|
[This response was prompted by Joe, drafted by Claude Code, and reviewed by Joe.] Done on both counts:
The second commit adds three regression tests to I've updated the PR description to cover both bugs and how they interlock (the |
a9ca1e5 to
9b1ba1f
Compare
XMLDBStore.evalWithCollection() wraps its Resource in try-with-resources. Since the BASE64_BINARY branch passes the caller's actual BinaryValue to LocalBinaryResource.setContent() (rather than copying it to a byte[]), the try-exit close() -> doClose() -> binaryValue.close() closed the caller's value, so reading it again after xmldb:store failed with "Underlying channel has been closed" (file-backed) or "The underlying InputStream has been closed" (database-backed), and storing the same value twice failed in getStreamLength(). XMLDBStore now takes a shared reference on the value's behalf (via incrementSharedReferences()) before lending it to the resource, so the resource's close() releases only that reference and the value remains readable by the caller. The reference is taken by the lender rather than inside LocalBinaryResource.setContent() because setContent has two kinds of callers with opposite ownership: query-result resources own their value (closing the resource must release it -- the contract pinned by FilterInputStreamCacheMonitorTest.binaryResult), while xmldb:store only lends the caller's value. For file-backed values this relies on BinaryValueFromFile's reference counting introduced in the previous commit. Adds three regression tests to AbstractBinariesTest (file-backed reuse, database-backed reuse, same value stored twice), exercised by the embedded, XML:DB (local and remote), and REST harnesses. Closes eXist-db#6552 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ThoADnh6VvDt5w8kz7d2d3
9b1ba1f to
f4bdf72
Compare
|
[This response was prompted by Joe, drafted by Claude Code, and reviewed by Joe.] CI caught a real problem with my first placement of the #6552 fix, now corrected in f4bdf72 (the second commit was amended in place).
So the reference is now taken by the lender: |
line-o
left a comment
There was a problem hiding this comment.
LGTM
Nitpick for the future: Verbose commentary explaining the concept of reference counting. A reference to an existing explanation outside the codebase might be all that is needed.
|
@line-o Thanks! Based on this and your reviews today in existdb-openapi, Claude wrote:
|
[This PR was prompted by Joe, drafted by Claude Code, and reviewed by Joe.]
This PR now fixes two distinct premature-close bugs in the binary-value lifecycle. Per @duncdrum's request in #6552 (comment), the fix for #6552 has been folded into this PR as a second commit, so this one PR closes that issue.
Closes #6552
Bug 1 — enclosed expressions close a file-backed binary (commit 1)
A file-backed binary value (
BinaryValueFromFile, e.g. fromfile:read-binaryorrequest:get-uploaded-file-data) that is used in an element or document constructor (an enclosed expression) and then read again could have its file channel closed underneath it, after which the second read failed with "Underlying channel has been closed":Root cause
BinaryValueFromFiledid not honor the shared-reference reference counting thatXQueryContext.enterEnclosedExpr()/exitEnclosedExpr()rely on:incrementSharedReferences()was a no-op ("we don't need reference counting…"), andclose()released theFileChannel/RandomAccessFileunconditionally.enterEnclosedExpr()increments the shared-reference count of each live binary value so a value that escapes an enclosed expression survives; the matchingexitEnclosedExpr()thenclose()s each, intending only to decrement a reference. ForBinaryValueFromFilethe increment did nothing and theclose()actually tore down the channel — so a value used in a constructor was closed immediately after the constructor, while it was still referenced.BinaryValueFromInputStreamwas unaffected because it already reference-counts throughAbstractFilterInputStreamCache.Fix
Give
BinaryValueFromFilethe same reference counting as its input-stream sibling: a counter starting at 1, incremented byincrementSharedReferences()and decremented byclose(), releasing the channel/file handle only when it reaches zero. Eager cleanup of values not shared out of a scope is preserved (their count goes 1 → 0 on cleanup).Bug 2 —
xmldb:storecloses the caller's binary value (commit 2; #6552)The regression reported in #6552: since #6467, storing a binary value with
xmldb:storeand then reading it again in the same query fails. Root-cause analysis is in #6552 (comment).Root cause
XMLDBStore.evalWithCollection()wraps itsResourcein try-with-resources. Since #6467 thexs:base64Binarybranch passes the caller's actualBinaryValuetoLocalBinaryResource.setContent()(before #6467 it was copied to abyte[]), and on try-exitAbstractEXistResource.close()→LocalBinaryResource.doClose()callsbinaryValue.close()— closing the caller's value, which the query may still need. This breaks file-backed and database-backed (util:binary-doc) values, and also breaks storing the same value twice.Fix
XMLDBStorenow takes a shared reference on the value's behalf (incrementSharedReferences()) before lending it to the resource, so the resource'sclose()releases only that reference and the value remains readable by the caller. The reference is taken by the lender rather than insideLocalBinaryResource.setContent()becausesetContenthas two kinds of callers with opposite ownership semantics: a query-result resource owns its value — closing the resource must release it, the contract pinned byFilterInputStreamCacheMonitorTest.binaryResult— whilexmldb:storeonly lends the caller's value. For file-backed values this depends on Bug 1's reference counting — which is why the two fixes belong in one PR: commit 2 without commit 1 would still fail for file-backed values.Verification matrix from the #6552 investigation (three reproducers, per build):
What changed
exist-core/.../xquery/value/BinaryValueFromFile.java— add thesharedReferencescounter;incrementSharedReferences()increments it;close()decrements and releases the channel only at zero (idempotent once released).exist-core/.../xquery/functions/xmldb/XMLDBStore.java— takes a shared reference on the binary value before lending it to the resource, so the resource'sclose()no longer closes the caller's value.exist-core/.../xquery/value/BinaryValueFromFileSharedReferenceTest.java— new unit test for the reference-counting contract.extensions/modules/file/.../AbstractBinariesTest.java— four new regression tests (see below), inherited by the embedded, XML:DB (local + remote), and REST harnesses.Test plan
BinaryValueFromFileSharedReferenceTest(unit): modelsenterEnclosedExpr()/exitEnclosedExpr()on a shared file-backed value; it must stay open and readable, then be released by the finalclose. Fails before the fix, passes after.AbstractBinariesTest#readBinaryUsedInElementConstructorThenReadAgain: the Bug 1 query. Fails with "Underlying channel has been closed" before commit 1, passes after.AbstractBinariesTest#readBinaryStoreThenReadAgain: Bug 2, file-backed (file:read-binary→xmldb:store→ read again).AbstractBinariesTest#binaryDocStoreThenReadAgain: Bug 2, database-backed (util:binary-doc→xmldb:store→ read again; failed with "The underlying InputStream has been closed").AbstractBinariesTest#readBinaryStoredTwice: Bug 2, same value stored twice (failed with "error while obtaining length of binary value" — the failure signature from the CI of eXist-db/public-repo, which currently carries a workaround for this).AbstractBinariesTesttests were run against a build without theXMLDBStorechange first: 12 failures with exactly the [BUG] Regression with binary streams closing #6552 error signatures across all harnesses (embedded, XML:DB local + remote, REST); with the change, all 26 pass.FilterInputStreamCacheMonitorTestpasses (3/3) — in particularbinaryResult, which pins the query-result resource-ownership contract that ruled out placing the reference-taking insideLocalBinaryResource.setContent().XMLDBStore.java— method NPath complexity and a parameter reassignment in an untouched method — left as-is to keep the diff minimal).The regression tests run main-module queries (not XQSuite test functions) on purpose: inside a module function Bug 1 is masked, because
ModuleContext.registerBinaryValueInstance()delegates registration to the parent/root context, whileenterEnclosedExpr()/exitEnclosedExpr()operate on theModuleContext's own (empty) deque — so the constructor'sexitEnclosedExpr()never sees the binary there and the close happens later viapopLocalVariables(after the read), harmlessly. The bug surfaces only where the binary and the enclosed expression share a context, i.e., in a main-module query. (That asymmetry inModuleContextis pre-existing and merely masks this bug; this PR does not change it.)Notes
xmldb:storefailure actually has a distinct root cause (Sequence.containsReferencenot recursing into nested map/array items, across five sequence types), now fixed separately in [bugfix] Sequence.containsReference: recurse into nested map/array items #6507. This PR fixes two sibling "binary value closed while still referenced" bugs: the enclosed-expression path (Bug 1) and thexmldb:storeresource-close path (Bug 2, [BUG] Regression with binary streams closing #6552).