Skip to content

App-test runner useTestDB swap mutates shared application.wheels.dataSourceName without a lock (race: stranded test datasource) #3427

Description

@bpamiri

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

  1. Register both <dataSourceName> and <dataSourceName>_test datasources
    in config/app.cfm.
  2. 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.
  3. 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:

  1. Capture originalDataSource from the configured datasource name (a
    static value) rather than from live application.wheels.dataSourceName.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions