Environment
- Wheels 4.0.6 / wheels-core (also reproduced on 4.0.5 vendored into a downstream app)
- Lucee 7 (the race is engine-independent — it is a shared application-scope mutation)
- Files:
vendor/wheels/tests/app-runner.cfm (ships into user apps as tests/app-runner.cfm)
vendor/wheels/tests/_assets/dispatch/TestDbResolver.cfc
Summary
The built-in app-test runner swaps to the <dataSourceName>_test datasource
(url.useTestDB=true) by mutating application.wheels.dataSourceName — a
value shared across every concurrent request on the same app instance — and
captures the "original" datasource by reading that same live shared value.
There is no lock around the read-capture, the swap, or the restore, so two
overlapping test runs race and can leave the application scope stranded on
the test database.
Repro
- Register both
<dataSourceName> and <dataSourceName>_test datasources
in config/app.cfm.
- Start two overlapping app-test runs against the SAME running app
instance — e.g. two wheels test CLI invocations, or a browser
/wheels/app/tests?useTestDB=true while a CLI run is in flight.
- Each run executes in
tests/app-runner.cfm:
local.originalDataSource = application.wheels.dataSourceName; (reads shared scope)
local.dbResolver.applyDataSource(wheelsScope = application.wheels, name = local.candidate); (mutates shared scope)
- runs TestBox
finally { ... applyDataSource(wheelsScope = application.wheels, name = local.originalDataSource); }
Expected
Each run's datasource swap is isolated to that run; after a run finishes,
application.wheels.dataSourceName is restored to the actually-configured
datasource regardless of interleaving, and a real (non-test) request never
observes the _test datasource.
Actual
Runs capture each other's already-swapped value as their "original"
(<name>_test), so the candidate becomes <name>_test_test and the
restore writes the wrong name back. Overlapping runs leave
application.wheels.dataSourceName stranded on the _test datasource
(and can clear cached model classes under the wrong datasource, since
applyDataSource() also does StructClear(wheelsScope.models)).
Observed in a downstream multi-tenant app on wheels-core 4.0.5 (Lucee 7):
during two separate merge-gate test runs this produced admin-digest churn
and seed/tenant corruption on the real (non-test) plane. That app now
carries a local workaround in its own tests/runner.cfm that captures the
configured control datasource name instead of the live scope value and
self-heals a tenant-shaped application.wheels.dataSourceName — mitigation,
not a fix.
Root cause
vendor/wheels/tests/app-runner.cfm:
local.originalDataSource = application.wheels.dataSourceName; // reads live shared scope
...
local.dbResolver.applyDataSource(wheelsScope = application.wheels, name = local.candidate);
vendor/wheels/tests/_assets/dispatch/TestDbResolver.cfc:
public void function applyDataSource(required struct wheelsScope, required string name) {
arguments.wheelsScope.dataSourceName = arguments.name;
if (StructKeyExists(arguments.wheelsScope, "models")) {
StructClear(arguments.wheelsScope.models);
}
}
The read-capture and both mutations are unguarded and not atomic, and
"original" is derived from mutable shared state rather than the configured
datasource name.
Suggested fix
Either or both:
- Capture
originalDataSource from the configured datasource name (a
static value) rather than from live application.wheels.dataSourceName.
- Serialize the capture + swap + restore with a
cflock (and restore to
the configured value, not the captured one), so concurrent runs cannot
interleave or strand the app scope on the twin.
Environment
vendor/wheels/tests/app-runner.cfm(ships into user apps astests/app-runner.cfm)vendor/wheels/tests/_assets/dispatch/TestDbResolver.cfcSummary
The built-in app-test runner swaps to the
<dataSourceName>_testdatasource(
url.useTestDB=true) by mutatingapplication.wheels.dataSourceName— avalue shared across every concurrent request on the same app instance — and
captures the "original" datasource by reading that same live shared value.
There is no lock around the read-capture, the swap, or the restore, so two
overlapping test runs race and can leave the application scope stranded on
the test database.
Repro
<dataSourceName>and<dataSourceName>_testdatasourcesin
config/app.cfm.instance — e.g. two
wheels testCLI invocations, or a browser/wheels/app/tests?useTestDB=truewhile a CLI run is in flight.tests/app-runner.cfm:local.originalDataSource = application.wheels.dataSourceName;(reads shared scope)local.dbResolver.applyDataSource(wheelsScope = application.wheels, name = local.candidate);(mutates shared scope)finally { ... applyDataSource(wheelsScope = application.wheels, name = local.originalDataSource); }Expected
Each run's datasource swap is isolated to that run; after a run finishes,
application.wheels.dataSourceNameis restored to the actually-configureddatasource regardless of interleaving, and a real (non-test) request never
observes the
_testdatasource.Actual
Runs capture each other's already-swapped value as their "original"
(
<name>_test), so the candidate becomes<name>_test_testand therestore writes the wrong name back. Overlapping runs leave
application.wheels.dataSourceNamestranded on the_testdatasource(and can clear cached model classes under the wrong datasource, since
applyDataSource()also doesStructClear(wheelsScope.models)).Observed in a downstream multi-tenant app on wheels-core 4.0.5 (Lucee 7):
during two separate merge-gate test runs this produced admin-digest churn
and seed/tenant corruption on the real (non-test) plane. That app now
carries a local workaround in its own
tests/runner.cfmthat captures theconfigured control datasource name instead of the live scope value and
self-heals a tenant-shaped
application.wheels.dataSourceName— mitigation,not a fix.
Root cause
vendor/wheels/tests/app-runner.cfm:vendor/wheels/tests/_assets/dispatch/TestDbResolver.cfc:public void function applyDataSource(required struct wheelsScope, required string name) { arguments.wheelsScope.dataSourceName = arguments.name; if (StructKeyExists(arguments.wheelsScope, "models")) { StructClear(arguments.wheelsScope.models); } }The read-capture and both mutations are unguarded and not atomic, and
"original" is derived from mutable shared state rather than the configured
datasource name.
Suggested fix
Either or both:
originalDataSourcefrom the configured datasource name (astatic value) rather than from live
application.wheels.dataSourceName.cflock(and restore tothe configured value, not the captured one), so concurrent runs cannot
interleave or strand the app scope on the twin.