Skip to content

Fix flaky CI tests: HTTP/2 hangs, broker/txn ordering races, and BrokerPool shutdown safety#6532

Merged
line-o merged 16 commits into
eXist-db:developfrom
duncdrum:dp-flaky-test-fixes
Jun 30, 2026
Merged

Fix flaky CI tests: HTTP/2 hangs, broker/txn ordering races, and BrokerPool shutdown safety#6532
line-o merged 16 commits into
eXist-db:developfrom
duncdrum:dp-flaky-test-fixes

Conversation

@duncdrum

@duncdrum duncdrum commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Addresses frequent causes of CI flake that PRs have been struggling with. Most changes are test-hardening and infrastructure fixes; the WebSocket work includes deliberate production fixes for cancel/timeout reliability under concurrent load.

Summary

Six categories of flaky-test fix, each with a distinct root cause:

  1. BrokerPool / TransactionManager shutdown safety
  2. HTTP/2 RST_STREAM hangs in test HTTP clients
  3. DBBroker / Txn try-with-resources close-order races
  4. WebSocket query-cancel visibility and lifecycle races
  5. WebSocket cancel protocol and serialize-path polling
  6. WebSocket max-execution-time wall-clock backstop (+ split unit/integration tests)

Root Causes and Fixes

1 — BrokerPool shutdown safety (BrokerPool.java, TransactionManager.java)

BrokerPool.get() checked only isShuttingDown() (terminal SHUTDOWN state) in its wait loop. Threads waiting for a free broker during SHUTTING_DOWN_SYSTEM_MODE — where securityManager is already nulled — could spin indefinitely. Fix: throw EXistException immediately when the pool is in any shutdown state (isShuttingDownOrDown()), and tighten the loop guard the same way.

TransactionManager.processSystemTasks() called pool.getSecurityManager() without checking pool state first, causing NPE during SHUTTING_DOWN_SYSTEM_MODE. Fix: early return guarded by isShuttingDownOrDown().

TransactionManagerTestHelper updated to expect the new isShuttingDownOrDown() call, which broke the EasyMock strict mock.

2 — RST_STREAM / HTTP/2 hang (AbstractHttpTest.java, MoveResourceTest.java)

JDK HttpClient defaults to HTTP/2. Under load the embedded Jetty sends RST_STREAM frames that silently stall connections. This caused GetParameterTest to hang for 38 minutes on CI, triggering the 45-minute Maven unit-test step timeout.

AbstractHttpTest.newHttpClient() is the shared factory for 11 RESTTest subclasses — forcing HTTP_1_1 there fixes all of them. MoveResourceTest.CheckThread had its own inline client with the same default; same fix applied. Also catch IOException in CheckThread's retry loop — transient connection failures arrive as IOException before any HTTP status, so the existing HTTP 5xx retry never fired.

XMLDBAuthenticateTest and AttributeTest were investigated but deliberately left unchanged. Both use CookieManager for session persistence; forcing HTTP/1.1 on those clients caused session-invalidation tests to return HTTP 500 instead of 200. Those tests are not affected by RST_STREAM hangs, so the fix does not apply there.

3 — DBBroker/Txn close-order race (DomEnhancingNodeProxyAdapterTest, DeepEmbeddedBackupRestoreTest, XQueryTrigger*Test, SendEmailIT)

BrokerPool.get() is thread-local: it reuses the broker already active on the thread, or draws a fresh one if none is active. Java closes try-with-resources variables in reverse declaration order, so declaring Txn before DBBroker can leave Txn.close() calling pool.getBroker() after the thread-local broker was already returned — IllegalStateException in NativeBroker.removeCurrentTransaction().

Fix: declare DBBroker before Txn in every affected block so the thread holds a broker across the entire transaction lifecycle.

XQueryUpdateTest uses a withBroker() helper plus Assume.assumeFalse(pool.isShuttingDownOrDown()) so a pool failure in one test does not cascade through subsequent @Before/@After calls.

4 — WebSocket cancel visibility (XQueryWatchDog.java, EvalWebSocketEndpointTest.java)

EvalWebSocketEndpointTest.cancellation was coin-flip pass/fail under CI.

JMM data race on terminate: kill() runs on the WebSocket message-handler thread; proceed() reads terminate on the query-execution thread. The field was a plain boolean. Fix: private volatile boolean terminate.

Cancel sent before watchdog registered: cancelQuery() looks up the watchdog in a registry; if cancel arrives before registration, it is silently dropped. Fix: wait on a progressLatch (signalled when progress / evaluating arrives) before sending cancel.

GC pressure in the test query: string($i) over hundreds of millions of iterations caused multi-second STW GC pauses on CI, during which proceed() could not run and cancel appeared late. Fix: bounded FLWOR with return () (no per-iteration allocation).

5 — watchdog.reset() race (QueryExecutor.java)

xquery.execute(resetContext=true) calls watchDog.reset() after the evaluating signal is sent. On slow CI, a client cancel roundtrip could complete between kill() and that internal reset(), clearing terminate and dropping the cancel. Fix: call watchDog.reset() before registerQuery(), pass resetContext=false to execute(), and call context.reset() in finally.

6 — Cancel protocol, serialize polling, and wall-clock timeout (EvalProtocol.java, EvalWebSocketEndpoint.java, QueryExecutor.java, WallClockQueryTimeout.java)

Cancel was fire-and-forget: no response when the query id was unknown; kill() during non-streaming serialize was never observed because proceed() only runs during evaluation. Fix: send error / cancelling acks on cancel; check isTerminating() / proceed() before serializeAll().

Cooperative timeout starvation: max-execution-time is only checked inside proceed(). Under CPU pressure the eval thread may not run for long stretches after evaluating, so the client never received a terminal message (the failure mode behind intermittent maxExecutionTime CI failures). Fix: schedule a daemon wall-clock timer when max-execution-time > 0; on expiry kill() the query and send a single terminal error if no other response was sent yet (terminalResponseSent gate prevents duplicates).

Test split: unit tests cover proceed() timeout and WallClockQueryTimeout scheduling; the WebSocket maxExecutionTime test is a short smoke (1s limit + 4s client wait) that relies on the server backstop rather than long client latch slack.

Known limitations (intentional scope)

  • Wall-clock backstop is WebSocket /ws/eval only — REST (XQueryServlet) still uses cooperative timeout only. No REST flakes were reported in this work.
  • max-execution-time applies from watchdog reset() at the start of evaluation (after compile), not from receipt of the WebSocket message — consistent with XQuery.execute() elsewhere.
  • Dual enforcement: cooperative watchdog when proceed() runs, plus wall-clock for client response guarantee.

What Changed

File Change
BrokerPool.java Throw on shutdown in fast-path; isShuttingDownOrDown() in wait loop
TransactionManager.java Early return in processSystemTasks() when pool is shutting down
TransactionManagerTestHelper.java Add isShuttingDownOrDown() mock expectation
AbstractHttpTest.java Force HTTP_1_1 in newHttpClient()
MoveResourceTest.java Force HTTP_1_1 in CheckThread; catch IOException in retry
XQueryWatchDog.java volatile boolean terminate
EvalProtocol.java PHASE_CANCELLING
EvalWebSocketEndpoint.java Cancel ack: error if unknown query, cancelling progress if accepted
QueryExecutor.java resetContext=false; serialize-path watchdog polling; wall-clock timeout + terminal gate
WallClockQueryTimeout.java Daemon scheduled backstop for max-execution-time
EvalWebSocketEndpointTest.java Bounded queries, cancel/timeout smoke tests, connection-cleanup hygiene
WallClockQueryTimeoutTest.java Unit tests for schedule / cancel
XQueryWatchDogTest.java Unit test for proceed() timeout
XQueryUpdateTest.java withBroker() helper + shutdown guard
DomEnhancingNodeProxyAdapterTest.java Swap DBBroker/Txn declaration order
DeepEmbeddedBackupRestoreTest.java Swap DBBroker/Txn declaration order
XQueryTrigger*Test.java Swap DBBroker/Txn declaration order
SendEmailIT.java Swap DBBroker/Txn declaration order
AGENTS.md Update known-build-issues / excluded-test notes
exist-core/pom.xml Correct FragmentsTest exclusion comment

Test Plan

  • TxnTest (7) + ReusableTxnTest (9) — 16/16 pass locally
  • DomEnhancingNodeProxyAdapterTest — 6/6 pass locally
  • XQueryTriggerSetUidTest, XQueryTriggerSetGidTest, XQueryTriggerChainTest — 3/3 pass locally
  • DeepEmbeddedBackupRestoreTest — passes locally
  • XQueryWatchDogTest — including proceed() timeout
  • WallClockQueryTimeoutTest
  • EvalWebSocketEndpointTest — 23/23 pass locally (cancellation, maxExecutionTime, rapidCancel, streaming, etc.)
  • FragmentsTest — confirmed still hangs locally; exclusion retained
  • XMLDBAuthenticateTest, AttributeTest — HTTP/1.1 fix tested and reverted
  • CI ubuntu unit job after rebase push

@duncdrum duncdrum requested a review from a team as a code owner June 26, 2026 15:51
@duncdrum duncdrum marked this pull request as draft June 26, 2026 16:08
@joewiz

joewiz commented Jun 26, 2026

Copy link
Copy Markdown
Member
Screenshot 2026-06-26 at 12 18 18

I've never been so happy to see a green checkmark.

@duncdrum

Copy link
Copy Markdown
Contributor Author

this isn't the final shape, the green run was lucky, I m working on it

@duncdrum duncdrum force-pushed the dp-flaky-test-fixes branch 2 times, most recently from 19e717a to 8ab77c9 Compare June 27, 2026 10:35
@duncdrum duncdrum marked this pull request as ready for review June 27, 2026 10:35
@duncdrum duncdrum marked this pull request as draft June 27, 2026 10:40
duncdrum and others added 3 commits June 27, 2026 12:43
…em tasks

BrokerPool.get() only checked isShuttingDown() (SHUTDOWN terminal state) in its
wait loop, so threads waiting for a free broker could spin indefinitely during
SHUTTING_DOWN_SYSTEM_MODE — the state where securityManager is already nulled.

Two fixes:
- Throw EXistException immediately when inactiveBrokers is empty and the pool is
  in any shutdown state (isShuttingDownOrDown), rather than entering the wait loop.
- Change the loop guard from isShuttingDown() to isShuttingDownOrDown() so the
  remaining path also exits cleanly during SHUTTING_DOWN_SYSTEM_MODE.

TransactionManager.processSystemTasks() calls pool.getSecurityManager() to obtain
the system subject. If the pool enters SHUTTING_DOWN_SYSTEM_MODE before this call,
getSecurityManager() returns null and the subsequent get() throws NPE. Add an early
return guarded by isShuttingDownOrDown() to prevent this.

TransactionManagerTestHelper updated to expect the new isShuttingDownOrDown() call
introduced in processSystemTasks, which broke the EasyMock strict mock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hangs

JDK HttpClient defaults to HTTP/2. Under load, the embedded Jetty server sends
RST_STREAM frames that silently stall HTTP/2 connections. This caused
GetParameterTest to hang for 38 minutes on CI, triggering the 45-minute Maven
unit-test step timeout and aborting the entire run.

AbstractHttpTest.newHttpClient() is the shared factory for 11 RESTTest subclasses.
Forcing HTTP_1_1 there eliminates the failure mode for all of them.

MoveResourceTest.CheckThread used its own inline HttpClient with the same default.
Fixed the same way. Also catch IOException in the retry loop — transient connection
failures arrive as IOException before any HTTP status is available, so the existing
HTTP 5xx retry never fired for them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… blocks

BrokerPool.get() is thread-local: it bumps the ref-count on whichever broker is
already active on the calling thread, or draws a fresh one from the pool if none
is active. Try-with-resources closes variables in reverse declaration order, so
if Txn is declared before DBBroker, the sequence on exit is:

  1. DBBroker.close() — returns broker X to the pool; thread now has no active broker
  2. Txn.close() — calls pool.getBroker() to run removeCurrentTransaction
     → may draw a different broker Y that never saw this transaction
     → NativeBroker.removeCurrentTransaction() throws IllegalStateException

Fix: declare DBBroker before Txn in every affected try-with-resources block so
the thread holds a broker reference across the entire transaction lifecycle.

XQueryUpdateTest is a different shape: its @test methods do not open transactions
directly, so there is no declaration-order issue there. Instead it gets a
withBroker() helper that (a) acquires a broker for the lambda body and (b) adds
an Assume.assumeFalse(pool.isShuttingDownOrDown()) guard so that a BrokerPool
failure in one test does not cascade through all subsequent @Before/@after calls.

Files fixed (declaration-order swap):
  exist-core:   DeepEmbeddedBackupRestoreTest, XQueryTriggerChainTest,
                XQueryTriggerSetGidTest, XQueryTriggerSetUidTest
  extensions:   DomEnhancingNodeProxyAdapterTest (setup + withTestDocument),
                SendEmailIT

Files fixed (withBroker helper + shutdown guard):
  exist-core:   XQueryUpdateTest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@duncdrum duncdrum changed the title Fix two pre-existing flaky test races (MoveResourceTest + XQueryUpdateTest) Fix flaky CI tests: HTTP/2 hangs, broker/txn ordering races, and BrokerPool shutdown safety Jun 27, 2026
AGENTS.md: MoveResourceTest no longer hangs after the HTTP/1.1 fix — remove it
from the warning. Document the two permanently excluded tests (FragmentsTest,
ConcurrencyTest) with their root causes.

exist-core/pom.xml: FragmentsTest comment previously claimed the test passes
locally in ~54s. Confirmed to hang locally as well (killed after 12 min);
root cause is not fully resolved by the isShuttingDownOrDown fix alone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@duncdrum duncdrum force-pushed the dp-flaky-test-fixes branch from 8ab77c9 to fb374f0 Compare June 27, 2026 11:01
@duncdrum duncdrum marked this pull request as ready for review June 27, 2026 11:02
@duncdrum

Copy link
Copy Markdown
Contributor Author

ok should be good to go now, had multiple all green runs during rebases with force pushes.

* @return a new {@link HttpClient}.
*/
public static HttpClient newHttpClient() {
// Force HTTP/1.1: RST_STREAM is an HTTP/2 frame; using HTTP/1.1 eliminates that failure mode.

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.

@reinhapa 😀

@dizzzz dizzzz requested review from a team, line-o, reinhapa and wolfgangmm June 27, 2026 11:41
@duncdrum duncdrum marked this pull request as draft June 27, 2026 12:43
@duncdrum duncdrum force-pushed the dp-flaky-test-fixes branch 3 times, most recently from 6357e04 to b398566 Compare June 27, 2026 16:58
duncdrum and others added 4 commits June 27, 2026 20:27
…ancellation

XQueryWatchDog.terminate was a plain boolean. kill() is called from the WebSocket
message-handler thread; proceed() is polled from the query-execution thread. Without
volatile, the JMM gives no guarantee the executing thread ever observes terminate=true,
making cancellation unreliable on multi-core systems under load.

The cancellation test exposed this: it consistently took ~30s (the full await timeout)
on CI because the executing thread saw a stale terminate=false well past the kill()
call. With volatile the cancel is visible at the very next proceed() call — milliseconds.

The test had a second bug: it sent the cancel after a hardcoded Thread.sleep(200),
before confirming the query had started executing and registered its watchdog. If the
worker thread was slow, cancelQuery() found no watchdog and silently dropped the cancel,
leaving the query to run until max-execution-time. The progressLatch (already wired up
in the message handler) is the correct synchronisation point — wait for the
"progress/evaluating" event before sending cancel.

Result: cancellation test drops from ~30s to ~2s with a 5s safety margin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous query (for $i in 1 to 999999999 return string($i)) allocated
999999999 String objects, triggering stop-the-world GC pauses several
seconds long on CI. During a GC pause the query thread cannot call
proceed(), so the volatile terminate flag goes unobserved for the duration
of the pause — causing the 5s cancel window to expire before the flag is
seen, even though the volatile fix itself is correct.

Two fixes:
1. Change query to 'for $i in 1 to 999999999 return ()': no per-iteration
   allocation, no GC pressure. The query thread stays schedulable and calls
   proceed() millions of times per second, so the volatile flag is observed
   within microseconds of kill().

2. Widen cancelledLatch timeout from 5s to 10s and raise max-execution-time
   to 30s (genuine fallback only). The max-execution-time was previously set
   to 5000ms — equal to our latch timeout — so its error message was racing
   the latch expiry and arriving after the test had already given up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cancels on CI

xquery.execute(resetContext=true) calls watchdog.reset() after the
"evaluating"
signal is sent. On slow CI machines, sendText() blocks long enough for
the client
cancel roundtrip to complete: kill() sets terminate=true, then
watchdog.reset()
immediately clears it to false, dropping the cancel silently.

Fix: call watchdog.reset() manually before
registerQuery()/sendProgress(), then
pass resetContext=false to xquery.execute() so the internal reset is
skipped.
Add context.reset() to the outer finally block to cover what
resetContext=false
no longer handles in xquery.execute().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ialization

Cancel was fire-and-forget: clients got no response when the query was
missing, and kill() during the non-streaming serialize path was never
observed because proceed() is only called during evaluation.

Send cancelling/error acks on cancel, check isTerminating/proceed()
before serializeAll(), and tighten cancel-related tests with bounded
FLWOR queries so they stay reliable under reuseForks.

Co-authored-by: Cursor <cursoragent@cursor.com>
duncdrum and others added 2 commits June 27, 2026 20:31
The cooperative watchdog only checks elapsed time inside proceed(). Under
CI load the eval thread may not run for long stretches after evaluating
starts, so the client never gets a terminal response.

Schedule a daemon timer when max-execution-time > 0; on expiry kill the
query and send a single terminal error if no other response was sent yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
…bSocket

- XQueryWatchDogTest: proceed() throws after elapsed limit
- WallClockQueryTimeoutTest: schedule fires, cancel prevents callback
- EvalWebSocketEndpointTest.maxExecutionTime: 1s limit, 4s client wait
  (relies on server backstop, not long latch slack)

Co-authored-by: Cursor <cursoragent@cursor.com>
@duncdrum duncdrum force-pushed the dp-flaky-test-fixes branch from cf51306 to 65a30e7 Compare June 27, 2026 18:39
@duncdrum

Copy link
Copy Markdown
Contributor Author

🧙 🪄 Fluctuo Stabilis

@duncdrum duncdrum marked this pull request as ready for review June 27, 2026 18:58
@joewiz

joewiz commented Jun 28, 2026

Copy link
Copy Markdown
Member

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

High-effort review (against the current diff); findings independently verified against the branch code. The HTTP/2-pinning de-flake is reasonable. The main concerns are concurrency defects in the new WebSocket cancel/timeout machinery.

1. Concurrent sendText on the non-thread-safe Basic remote → uncaught IllegalStateException

EvalWebSocketEndpoint.java (handleCancel) / QueryExecutor.java

The eval runs on a separate ws-eval-worker thread pool (Executors.newCachedThreadPool), and its sendProgress/sendResult calls go through session.getBasicRemote().sendText(...). handleCancel runs on the WebSocket container's message thread (onMessage) and also calls session.getBasicRemote().sendText(...). The Basic remote is not thread-safe, so a client that cancels while its query is still streaming (the normal case) can drive two concurrent sendText calls on the same remote → IllegalStateException ("remote endpoint was in state [TEXT_FULL_WRITING]"). The send helpers / handleCancel catch only IOException, so the RuntimeException propagates out of onMessage and tears down the session / corrupts the frame. Previously handleCancel only logged — so this is a regression. The Basic remote needs single-threaded access (serialize all sends through the eval worker, or use a per-session send lock / the async remote).

2. One shared blocking timeout scheduler → a stalled client blocks every query's kill

WallClockQueryTimeout.java

private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(...) — a single shared scheduler runs every query's timeout callback, and handleWallClockTimeout does watchDog.kill(0) followed by a blocking sendError(...)getBasicRemote().sendText. If one client's connection stalls, that send blocks the single scheduler thread indefinitely, so every other query's timeout callback queues behind it and their watchDog.kill(0) never runs — runaway queries server-wide are not terminated at their max-execution-time, defeating the very backstop this PR adds. The timeout callback must not block on client I/O.

3. Late cancel after success reports a successful query as failed

EvalWebSocketEndpoint.java (handleCancel)

handleCancel() sends an error frame ("Query not found or already completed") when cancelQuery() returns false, instead of only LOG.debug. Since execute()'s finally unregisters the query on success, a cancel arriving just after (or racing) normal completion takes the false branch — so the client receives an extra error frame for a query that actually returned its full result.

Lower-severity items

  • BrokerPool.get() throws during shutdown — the broker-creation path now throws EXistException whenever isShuttingDownOrDown(); in-flight requests, scheduled jobs, and unguarded test teardowns calling get() during shutdown can intermittently throw (potentially the same CI flakiness this targets).
  • Cancelled timeout futures retain their closuresnewSingleThreadScheduledExecutor has removeOnCancelPolicy=false, so cancelled ScheduledFutures stay in the delay queue until their full max-execution-time, retaining session/query-string references (heap growth under load). Use ScheduledThreadPoolExecutor(1) + setRemoveOnCancelPolicy(true).
  • Backstop is ws/eval-only — the can't-fire-under-CPU-pressure weakness exists for every watchDog.setTimeout() consumer (REST, RESTXQ, scheduler, XML-RPC); generalizing it in XQueryWatchDog would cover all entry points.
  • Test masking / coverage lossXQueryUpdateTest wraps every test in Assume.assumeFalse(isShuttingDownOrDown()) (a lifecycle regression now silently skips instead of failing); AbstractHttpTest.newHttpClient() pins HTTP_1_1, removing HTTP/2 coverage from every test using the helper.
  • Broker/txn ordering worked around — fixed by manual try-with-resources reordering across ~6 test files plus a "caller must hold an active broker" precondition, rather than fixing Txn.close()'s ambient-broker dependency at the source.
  • UK spellingEvalProtocol.PHASE_CANCELLING = "cancelling" (US is "canceling"). Note this is a wire-protocol constant value, so changing it later is client-visible — worth settling now.

Bottom line

Findings 1–3 are real concurrency defects in the new cancel/timeout code; the first two (unsynchronized concurrent sendText, and a shared blocking scheduler that defeats the backstop) are serious enough to address before merge.

@duncdrum

Copy link
Copy Markdown
Contributor Author

Thx @joewiz mostly good findings. Making sure our codebase fully runs on http 2 is out of scope here. Our ci is stalling because of http 2 rst-stream, this affects every local build and ci run. Getting reliable builds back is top priority. Http 2 feature support is something to pick up in a separate pr.

duncdrum and others added 3 commits June 29, 2026 10:53
…ut paths

Three concurrency defects flagged in code review (joewiz):

1. handleCancel() called getBasicRemote().sendText() from the WebSocket
   container's message thread while the eval worker was also sending via the
   basic remote — Basic remote is not thread-safe and throws
   IllegalStateException("remote endpoint was in state [TEXT_FULL_WRITING]").
   Revert to the original behaviour: cancelQuery() + debug-log only. The eval
   worker already sends the cancelled frame when it detects isTerminating().
   This also eliminates the false-error race (finding 3): a cancel arriving
   just after completion no longer sends an error for a query that succeeded.

2. WallClockQueryTimeout used a newSingleThreadScheduledExecutor whose single
   thread was blocked by handleWallClockTimeout calling getBasicRemote().sendText().
   A stalled client connection would freeze the scheduler indefinitely, preventing
   watchDog.kill() from running for every other query — defeating the backstop.
   Switch to getAsyncRemote().sendText() which returns immediately and queues
   the write without blocking the scheduler thread.

3. Cancelled ScheduledFutures were retained in the delay queue until their full
   delay elapsed, holding references to session/query closures. Enable
   setRemoveOnCancelPolicy(true) on the ScheduledThreadPoolExecutor so cancelled
   futures are removed immediately.

Remove the now-unused PHASE_CANCELLING constant from EvalProtocol.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…in XQueryUpdateTest

Assume.assumeFalse(isShuttingDownOrDown()) silently skips a test when the
BrokerPool is unexpectedly down, masking regressions as passes.

With the BrokerPool.get() guard added in this PR, an operational-state
violation now throws EXistException("BrokerPool is not operational") —
a visible test failure rather than a silent skip.

The @after teardown retains its own isShuttingDownOrDown() guard, which
is correct: teardown should not compound or obscure an upstream failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The WebSocket WallClockQueryTimeout introduced a wall-clock backstop to
guarantee queries are killed even when the eval thread is CPU-starved and
never calls proceed(). That weakness exists for every watchDog.setTimeout()
consumer: REST, RESTXQ, XML-RPC, and the scheduler all rely solely on
proceed() to enforce timeouts.

Move the kill backstop into XQueryWatchDog itself: setTimeout() and
setTimeoutFromOption() now schedule a kill(0) on a shared single-daemon-
thread ScheduledThreadPoolExecutor (removeOnCancelPolicy=true). The future
is cancelled in reset() and cleanUp() so completed or timed-out queries
don't fire redundant kills.

WebSocket queries continue to use WallClockQueryTimeout for the transport-
specific "send async error response" step; the watchdog kill fires first
(or simultaneously) and is idempotent. All other entry points gain the
same wall-clock guarantee without any caller changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@duncdrum

duncdrum commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @joewiz — addressed findings 1–3 and two of the lower-severity items. The HTTP/2 coverage point is out of scope as noted.

Concurrent sendText (finding 1) + false error on late cancel (finding 3)handleCancel no longer calls getBasicRemote().sendText() from the WebSocket container's message thread. It now just calls cancelQuery() + debug-log. The eval worker already owns all terminal frames; a cancel after completion sends nothing rather than a spurious error. (ea6b756)

Blocking wall-clock scheduler (finding 2)handleWallClockTimeout switched from getBasicRemote().sendText() to getAsyncRemote().sendText(), so the shared scheduler thread never blocks on client I/O. Also enabled setRemoveOnCancelPolicy(true) to release session/query closures immediately on cancel. (ea6b756)

Assume.assumeFalse masking — removed from XQueryUpdateTest.withBroker(); the BrokerPool.get() guard added in this PR throws EXistException if the pool is down, producing a visible failure instead of a silent skip. The @After teardown retains its own guard (correct — teardown should not compound an upstream failure). (3637820)

Wall-clock backstop generalized into XQueryWatchDogsetTimeout() and setTimeoutFromOption() now schedule a kill(0) on a shared daemon-thread ScheduledThreadPoolExecutor (removeOnCancelPolicy=true). This covers REST, RESTXQ, XML-RPC, and the scheduler automatically; the WebSocket WallClockQueryTimeout still handles the transport-specific async error send. (c71fb4b)

duncdrum and others added 3 commits June 29, 2026 11:42
…now throws TimeoutException

When the KILL_SCHEDULER fires kill(0) within the timeout window, terminate
becomes true before proceed() is called. The if(terminate) fast-path was
throwing plain TerminatedException rather than TimeoutException, causing
XQueryWatchDogTest.proceedThrowsTimeoutWhenElapsedExceedsLimit to fail on
fast CI machines (1 ms timeout fires before proceed() runs).

Fix: in the if(terminate) path, re-check elapsed time and throw
TimeoutException if the timeout was genuinely exceeded — same condition
as the normal elapsed-time path below.

Also introduces killAsTimeout(long)/isTimedOut() alongside kill(long) to
let callers distinguish a scheduler-triggered timeout from a client cancel,
and changes the internal KILL_SCHEDULER to call killAsTimeout(0) so that
the timedOut flag is set correctly when the scheduler fires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nsaction start

TransactionManager.close() was calling pool.getBroker() (a thread-local
lookup) to find the broker for removeCurrentTransaction(). In try-with-resources
blocks where the broker is closed before the transaction, that lookup returns
a different broker or fails entirely, leaving the stale transaction registered
on the wrong broker.

Fix: capture the broker reference in Txn.owningBroker at the point
addCurrentTransaction() is called (inside doBeginTransaction), then use that
stored reference in close() instead of re-fetching from the pool. Falls back
to the pool lookup if owningBroker is null (e.g. system transactions that
bypass doBeginTransaction).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…et response

QueryExecutor was calling watchDog.kill(0) from the wall-clock timeout
callback and routing all terminations through sendCancelledIfAbsent(),
which sends a "cancelled" response even when the query was killed by the
server-side timeout — not a client request.

Fix: use watchDog.killAsTimeout(0) from handleWallClockTimeout so the
timedOut flag is set. Route through reportTerminationOrError() at the
two early-exit points (pre-serialization and mid-stream), which checks
!watchDog.isTimedOut() to send "cancelled" only for genuine client
cancels and falls through to sendError() for server-side timeouts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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 can reliably run the unit tests on my local machine again.
From everything I can see this does increase overall reliability and stability of exist-core
Thanks @duncdrum!

@line-o line-o merged commit 9b51fbf into eXist-db:develop Jun 30, 2026
9 checks passed
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.

4 participants