Skip to content

COLDBOX-1454: fix interceptor buffer pool race between a request and its async announce thread - #705

Merged
lmajano merged 3 commits into
developmentfrom
claude/wonderful-sagan-i425oq
Sep 25, 2026
Merged

lmajano merged 3 commits into
developmentfrom
claude/wonderful-sagan-i425oq

Conversation

@lmajano

@lmajano lmajano commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Description

Fixes COLDBOX-1454: InterceptorService.getLazyBuffer()'s per-request buffer pool (added in #701 as a performance optimization) races between a request thread and its own announce( ..., async: true ) thread, throwing can not pop Element from array, array is empty in production.

Root cause

getLazyBuffer()'s pooling decision only excluded the announce() call that itself spawns an async/asyncAll thread. It missed that code running inside that spawned thread can make its own synchronous announce() calls — most commonly WireBox's afterInstanceAutowire announcement, fired on every getInstance() call — and those still pooled against the same request-scoped array as the original request thread.

request scope — and thus the pool array — is shared between a request and any cfthread spawned from it. CFML/BoxLang arrays aren't thread-safe, so two real concurrent threads (the request thread and an async announce()'s thread) popping/releasing the same array at once can corrupt it.

Fix

Also check controller.getUtil().inThread() — the same check InterceptorState.process() already uses for its own async dispatch decision — so a synchronous announce() executing inside any cfthread always gets a fresh, unpooled buffer, never touching the array the request thread is using:

var pooled = !arguments.async && !arguments.asyncAll && !variables.controller.getUtil().inThread()

This matches the fix suggested in the ticket.

Validation

  • Added regression coverage in InterceptorserviceTest.cfc for both the pooled/reused path and the never-pooled paths (async/asyncAll, and now inside-a-thread) — 25/25 passing locally against a live BoxLang/Lucee stack (bypassing this sandbox's ORM/MySQL-dependent shared test harness by running the spec directly via TestBox).
  • Also ran the neighboring InterceptorStateTest.cfc suite (16/16 passing) to confirm no collateral effects, since it shares the same utility.inThread() pattern.
  • Attempted to directly reproduce the reported race with concurrent cfthreads hammering getInstance() on both the main and spawned threads; it did not reproduce in this sandbox (Lucee 7 here vs. Lucee 6.2.7 in the report — possibly a more forgiving array implementation, or just scheduling luck). The fix is still correct by construction: it removes cross-thread access to the shared pool array entirely rather than reducing collision odds, and it matches the reporter's own root-cause diagnosis and suggested fix.

Type of change

  • Bug fix

Checklist

  • I have commented my code, particularly in hard-to-understand areas
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes (see Validation — full TestBox suite run blocked by this sandbox's missing MySQL/Lucee ORM extension, unrelated to this change)

🤖 Generated with Claude Code

https://claude.ai/code/session_01KiK6DMek9iJMuYk2PzcjPj


Generated by Claude Code

… and its async announce thread

InterceptorService.getLazyBuffer()'s per-request buffer pool only
skipped pooling for the announce() call that itself spawns an async/
asyncAll thread. It missed that code running *inside* that spawned
thread can make its own synchronous announce() calls - most commonly
WireBox's afterInstanceAutowire announcement, fired by every
getInstance() call - which still pool against the same request-scoped
array as the original request thread.

`request` scope, and thus the pool array, is shared between a request
and any cfthread spawned from it. CFML/BoxLang arrays aren't
thread-safe, so two real concurrent threads (the request thread and an
async announce()'s thread) popping/releasing the same array can
corrupt it, throwing "can not pop Element from array, array is empty"
mid-request.

Also check controller.getUtil().inThread() (the same check
InterceptorState.process() already uses for its own async dispatch
decision) so a synchronous announce() executing inside any cfthread
always gets a fresh, unpooled buffer - never touching the array the
request thread is using.

Added regression coverage for both the pooled (reused) and
never-pooled (inside a thread / not asked to pool) paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KiK6DMek9iJMuYk2PzcjPj
Copilot AI lite review requested due to automatic review settings September 25, 2026 06:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

The regression test stubs inThread() instead of exercising an actual joined spawned thread.

Review effort: Lite
Findings: None

What changed in this PR

Fixes a race in request-scoped interceptor buffer pooling when announcements execute in spawned threads.

Changes:

  • Disables pooling inside threads.
  • Adds regression tests for pooled and unpooled buffer behavior.
File Summary
tests/​specs/​web/​services/​InterceptorserviceTest.cfc Adds buffer pooling regression coverage.
system/​web/​services/​InterceptorService.cfc Prevents shared pool access from spawned threads.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

An async announce()'s thread can itself become unbound from the
request that spawned it - a fire-and-forget async announce, or one
nobody joined, can keep running after the request has already ended.
By the time that orphaned thread's own work touches the buffer pool,
`request` scope may no longer be the one the pool was built against,
or may not be usable at all.

The pool is a performance nicety, never load-bearing for correctness,
so getLazyBuffer()/releaseLazyBuffer() now treat any failure reading
or writing it the same as "no pool available": fall back to a fresh,
unpooled buffer (get) or silently drop it (release), rather than
letting that propagate out of announce().

Added regression tests for both directions, and hardened the test
file's setup() to clear any leftover pool state between tests, since
request scope - and thus the pool - persists across every test in a
TestBox run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KiK6DMek9iJMuYk2PzcjPj
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Test Results

0 tests  ±0   0 ✅ ±0   0s ⏱️ ±0s
0 suites ±0   0 💤 ±0 
0 files   ±0   0 ❌ ±0 

Results for commit b357598. ± Comparison against base commit 81b28e6.

♻️ This comment has been updated with latest results.

… in tests

CI showed 16 errors on lucee@5/6, all in InterceptorserviceTest.cfc:
mockController.$( "getUtil", ... ) made MockBox generate a stub method
whose inferred return type was "string", then fail to cast the real
Util component (or, in one test, a TestBox Stub) to it.

Switched to mockController.setUtil( ... ), a real accessor-generated
setter rather than a MockBox $() stub - the same pattern setLogBox()/
setWireBox()/setCacheBox() already use for their own properties in
this same setup(), and which doesn't go through MockBox's return-type
inference at all.

Lucee 5/6 aren't available in this sandbox (no cached artifact, and
ForgeBox is unreachable here) to reproduce directly, but this removes
the exact code path CI's stack trace pointed at, in favor of a pattern
already proven safe on every engine in this same file. Re-verified
27/27 passing on Lucee 7 across repeated runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KiK6DMek9iJMuYk2PzcjPj

lmajano commented Sep 25, 2026

Copy link
Copy Markdown
Member Author

TestBox Report adobe@2023-11 is failing on this PR's head (b357598), but it's not this PR's failure: ModuleServiceTest.cfc → "Still resolves a handler-only URL for a module with only a mandatory-action route" → Error loading module routes as the module requested 'test-module-conventions' is not loaded. This PR only touches InterceptorService.cfc and InterceptorserviceTest.cfc — nothing in module loading/routing.

The base development branch shows the same intermittent pattern at a single unchanged commit (e.g. run 25885310481 fails adobe@2025 and boxlang-cfml@be, while the immediately preceding run at the same commit is green), so this looks like a pre-existing module-loading-order flake in the test harness, not something introduced here.

I don't have permission to re-run the failed job from here (rerun-failed-jobs returned 403). No fix for the flake exists yet, and making the test harness's module load ordering robust is outside this PR's scope. Will keep watching for CI status changes.


Generated by Claude Code

@lmajano
lmajano merged commit 0a20ed3 into development Sep 25, 2026
25 of 28 checks passed
@lmajano
lmajano deleted the claude/wonderful-sagan-i425oq branch September 25, 2026 06:33
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.

3 participants