Skip to content

[bugfix] Sequence.containsReference: recurse into nested map/array items#6507

Open
joewiz wants to merge 3 commits into
eXist-db:developfrom
joewiz:bugfix/valuesequence-containsreference-nested
Open

[bugfix] Sequence.containsReference: recurse into nested map/array items#6507
joewiz wants to merge 3 commits into
eXist-db:developfrom
joewiz:bugfix/valuesequence-containsreference-nested

Conversation

@joewiz

@joewiz joewiz commented Jun 21, 2026

Copy link
Copy Markdown
Member

[This PR was prompted by Joe, drafted by Claude Code, and reviewed by Joe.]

Summary

A file-backed binary value (BinaryValueFromFile, e.g. from request:get-uploaded-file-data or file:read-binary) that is nested inside a map (or array) held by a sequence could have its file channel closed while it was still referenced — after which any read failed with "Underlying channel has been closed". The most visible symptom: a multipart upload stored through a routing framework (Roaster / existdb-openapi POST /api/db/.../install) failed at xmldb:store with:

error while obtaining length of binary value <filename>

Root cause

The general-purpose value sequences checked only direct reference equality and did not recurse into items that are themselves containers — unlike MapType.containsReference and ArrayType.containsReference, which both do value == item || value.containsReference(item):

// e.g. ValueSequence (before)
public boolean containsReference(final Item item) {
    for (int i = 0; i <= size; i++) {
        if (values[i] == item) {       // direct items only
            return true;
        }
    }
    return false;
}

containsReference is the guard eXist uses to avoid destroying a value that is part of a result. When a let/function scope pops, XQueryContext.popLocalVariablesVariableImpl.destroy<sequence>.destroy<value>.destroy(context, contextSequence), and BinaryValueFromFile.destroy only skips closing when contextSequence.containsReference(this). For a binary returned nested in a map inside a sequence, that check returned false, so the channel was closed even though the value was still reachable through the returned map.

Five general-purpose sequence types had this gap: ValueSequence, ArrayListValueSequence, OrderedValueSequence, PreorderedValueSequence, and SubSequence — any of which can be the contextSequence while holding a map/array. (MapType/ArrayType already recurse correctly; RangeSequence holds only integers and every NodeSet holds only nodes, so none of those can nest a map/array.)

This is precisely the path the Roaster / existdb-openapi multipart upload tripped: request:get-uploaded-file-data is carried as $request?body?file?data (Roaster's form-data binary shape, map { name, data, size } produced by for ... return map { "data": $data[$index] }), and the deferred handler's xmldb:store then read a value whose channel had already been closed by the enclosing function's variable cleanup.

Fix

Make each affected sequence's containsReference recurse into container items, mirroring MapType/ArrayType:

if (i == item || (i instanceof Sequence seq && seq.containsReference(item))) {

How it was found

Reproduced end-to-end against the real Roaster body:parse deployed into a local eXist, instrumenting BinaryValueFromFile to capture the close-site. The close fired from popLocalVariables → destroy with containsReference returning false on a ValueSequence of maps; the fix flips it to true and the upload stores cleanly. Reduced to a pure-XQuery / direct unit-test case, then a containsReference audit of every Sequence implementation found the four sibling types with the identical gap.

What changed

  • exist-core/.../xquery/value/{ValueSequence, ArrayListValueSequence, OrderedValueSequence, PreorderedValueSequence, SubSequence}.javacontainsReference recurses into nested container items.

Test plan

  • ContainsReferenceNestedTest (unit): asserts ValueSequence, ArrayListValueSequence and SubSequence detect an item nested in a map (and don't report an unrelated item). Each fails without the fix. OrderedValueSequence/PreorderedValueSequence take the identical one-line change (their OrderSpec construction is not exercised directly).
  • FileBinaryNestedInMapTest (file module): a file:read-binary value returned nested in a map inside a sequence from a function, then read. Fails with "Underlying channel has been closed" before the fix, passes after.
  • Verified end-to-end against the real Roaster multipart route (stores byte-identical, no error).
  • Codacy/PMD clean on the changed methods (pre-existing findings elsewhere in those files are untouched).

Notes

The general-purpose value sequences checked only direct reference equality
(item == values[i]) and did not recurse into items that are themselves
containers, unlike MapType.containsReference and ArrayType.containsReference
which both do `value == item || value.containsReference(item)`. Affected:
ValueSequence, ArrayListValueSequence, OrderedValueSequence,
PreorderedValueSequence, and SubSequence. (NodeSets hold only nodes and
RangeSequence only integers, so neither can nest a map/array; both are
unaffected.)

As a result, a value nested inside a map (or array) held by such a sequence
was invisible to the variable-cleanup guard: when a let/function scope pops,
XQueryContext.popLocalVariables -> VariableImpl.destroy ->
<sequence>.destroy -> <value>.destroy(context, contextSequence) skips closing
a value only if `contextSequence.containsReference(value)`. For a file-backed
binary returned nested in a map inside a sequence, that guard returned false,
so the binary's file channel was closed while still referenced through the
returned map. A later read then failed with "Underlying channel has been
closed", surfaced (e.g.) as "error while obtaining length of binary value"
from xmldb:store.

This is exactly the path the Roaster / existdb-openapi multipart upload
tripped: request:get-uploaded-file-data, carried as $request?body?file?data
(Roaster's form-data binary shape: map { name, data, size }), failed at
xmldb:store. Reproduced with the real Roaster body:parse and reduced to the
test cases below.

Fix: make each general-purpose sequence's containsReference recurse into
container items, mirroring MapType/ArrayType.

Tests:
- ContainsReferenceNestedTest (unit): directly asserts ValueSequence,
  ArrayListValueSequence and SubSequence detect an item nested in a map
  (fails without the fix). OrderedValueSequence/PreorderedValueSequence take
  the identical one-line fix (their OrderSpec construction is not exercised
  directly).
- binary-nested-in-map.xqm (XQSuite, file module): a file:read-binary value
  returned nested in a map inside a sequence and escaping a function, then
  read; fails with "Underlying channel has been closed" before the fix. The
  inner one-iteration FLWOR is load-bearing -- it wraps the map in a
  ValueSequence; a bare map { ... } goes through MapType.containsReference,
  which already recurses, and would not exercise the bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@joewiz joewiz requested a review from a team as a code owner June 21, 2026 04:26
joewiz added a commit to joewiz/existdb-openapi that referenced this pull request Jun 21, 2026
…ta response)

cy.request with a FormData body uploads the bytes correctly (the package
installs server-side) but returns an empty `{}` response body, so the install
result was lost and the multipart assertions failed. Switch the upload helper to
the browser's native fetch, which runs same-origin to baseUrl and returns the
real JSON response. uploadXar() now resolves to a normalized { status, body }.

All 13 packages.cy.js cases pass against a build carrying eXist-db/exist#6507
(the core fix for the file-backed binary closed across nested map/array items).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@Test
public void valueSequenceDetectsItemNestedInMap() throws Exception {
withMapNesting((map, nested, notNested) -> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be rewritten to

final ValueSequence vs = withMapNesting() (map, nested, notNested) -> {
    final ValueSequence vs = new ValueSequence();
    vs.add(map);
    return vs;
});

assertTrue(vs.containsReference(nested));
assertFalse(vs.containsReference(notNested));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or a custom assertion maybe?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to revise my earlier review, the tests use some "clever" construct for testing that is hard to follow.
Preferrably, there is a simpler more straightforward solution. This can be more verbose and duplicate more code as long as it is easy to follow.

@line-o line-o left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to see Codacy to like the new junit tests. But this is not a blocker for me.

…erface helper

Per review feedback on PR eXist-db#6507: replace the withMapNesting(...) lambda
indirection with straightforward, inlined per-test setup. More verbose and
slightly duplicated, but easier to follow at a glance. No change to coverage:
each test still builds map { "data": $nested } in a fresh context, wraps it in
the sequence type under test, and asserts the nested item is detected while a
sibling item is not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@joewiz

joewiz commented Jun 21, 2026

Copy link
Copy Markdown
Member Author

[This response was prompted by Joe, drafted by Claude Code, and reviewed by Joe.]

Thanks @line-o. Both addressed in be7e255:

  • Simpler test. Dropped the withMapNesting(...) functional-interface helper and inlined the setup in each test method. It's a bit more verbose and duplicates the map { "data": $nested } construction three times, but each test now reads top to bottom with no indirection.
  • Codacy. PMD is clean on the rewritten ContainsReferenceNestedTest. All three tests still pass (Tests run: 3, Failures: 0).

joewiz added a commit to joewiz/existdb-oxygen-plugin that referenced this pull request Jun 21, 2026
… & Install

Move package installs onto existdb-openapi's multipart endpoint
(POST /api/packages/install, the .xar in the "file" part), dropping xst:

- Package Manager: enable the previously-disabled "Install .xar…" — pick a
  local .xar (via Oxygen's own file chooser, so it honors the "Use platform file
  chooser" preference) and upload it; refresh the list; report name/version.
- Build & Install (Project pane): install the built .xar via the same multipart
  upload instead of shelling out to `xst package install`. No external tool
  needed; credentials come from the resolved saved connection.
- ExistClient.installPackageFile now parses the success response and returns
  InstalledPackage(name, version, target); throws IOException with the server
  message on failure.
- Package Manager shows an indeterminate progress spinner while a server op
  (install/update/remove/local-.xar) runs — large packages take time.

Requires an existdb-openapi build whose packages:install reads the multipart
file part (consolidated upload handler + eXist-db/exist#6507). Verified
end-to-end against the trio. README + the Build & Install docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
declare
%test:assertEquals("SERVER_SECRET=123!")
function bnm:binary-nested-in-map-survives-function-return() {
let $directory := helper:get-test-directory($bnm:suite)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lines 64 and 65 should be in a setup function.

@joewiz joewiz Jun 22, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[This response was prompted by Joe, drafted by Claude Code, and reviewed by Joe.]

Done in 9a11664 — fixture creation now lives in a %test:setUp function, symmetric with the %test:tearDown. One wrinkle worth noting: helper:get-test-directory embeds a fresh util:uuid() on every call, so computing the directory independently in setUp and in the test would put the fixture and the read in different directories. I compute it once in a module variable ($bnm:directory) that both share. FileTests stays green.

Per review feedback on PR eXist-db#6507: the fixture setup (create the test directory and
write the .env fixture) now lives in a %test:setUp function, symmetric with the
existing %test:tearDown, instead of inline in the test body.

helper:get-test-directory embeds a fresh uuid on every call, so the directory is
computed once in a module variable ($bnm:directory) shared by setUp and the test,
rather than recomputed per function (which would land the fixture and the read in
different directories).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants