Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/integration-plan-null-refs.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed a flaky bare `NullPointerException` during model instantiation on Lucee 7 by validating the cached component-integration plan once per request and rebuilding it in place when it contains null function references — a null ref was previously copied into every materialized model/controller instance.
81 changes: 76 additions & 5 deletions vendor/wheels/global/objects.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,69 @@
application.wheels.integrationPlans[arguments.path] = local.plan;
}
}
return application.wheels.integrationPlans[arguments.path];
local.plan = application.wheels.integrationPlans[arguments.path];

// Once per request (per path), validate the cached plan. On Lucee 7 a
// cached function reference can come back as Java null (#3457) — e.g.
// when the plan was first built while the engine was still compiling
// the mixin component. A null ref is then written into every
// materialized instance's variables/this scope, and Lucee throws a
// bare NullPointerException when it later enumerates the component.
// Rebuild the plan in place when that happens.
//
// The flag lives in a request-scope struct rather than a dotted
// request-scope key: RustCFML resolves dots in request-scope keys as
// paths (nested writes, missed deletes), so a flat dotted key never
// round-trips there.
if (!StructKeyExists(request, "wheelsIntegrationPlanChecks")) {
request.wheelsIntegrationPlanChecks = {};
}
local.planKey = LCase(arguments.path);
if (!StructKeyExists(request.wheelsIntegrationPlanChecks, local.planKey)) {
request.wheelsIntegrationPlanChecks[local.planKey] = true;
if ($integrationPlanHasNullRefs(local.plan)) {
local.plan = $buildComponentIntegrationPlan(arguments.path);
lock name="wheels.integrationPlans.#application.applicationName#" type="exclusive" timeout="10" {
application.wheels.integrationPlans[arguments.path] = local.plan;
}
$warnNullIntegrationPlanRefs(arguments.path);
}
}
return local.plan;
}

/**
* Internal. Whether any entry in an integration plan carries a null (or
* missing) function reference — a plan in that state would write null
* members into every materialized instance (#3457).
*/
public boolean function $integrationPlanHasNullRefs(required array plan) {
for (local.comp in arguments.plan) {
for (local.pm in local.comp.publicMethods) {
if (!StructKeyExists(local.pm, "ref") || IsNull(local.pm.ref)) {
return true;
}
}
}
return false;
}

/**
* Internal. One-time warning when a cached integration plan had to be
* rebuilt because it contained null function references (#3457).
*/
public void function $warnNullIntegrationPlanRefs(required string path) {
try {
if (StructKeyExists(getFunctionList(), "writeLog")) {
writeLog(
file = "wheels",
type = "warning",
text = "Wheels rebuilt a cached component integration plan for '#arguments.path#' because it contained null function references (see issue ##3457). If this repeats on every request, the engine's class cache may be stale — restart the server."
);
}
} catch (any e) {
// Logging must never fail the request.
}
}


Expand Down Expand Up @@ -169,10 +231,19 @@
local.fEnd = ArrayLen(local.fns);
for (local.f = 1; local.f <= local.fEnd; local.f++) {
if (local.fns[local.f].access == "public") {
ArrayAppend(local.publicMethods, {
name = local.fns[local.f].name,
ref = local.instance[local.fns[local.f].name]
});
local.ref = local.instance[local.fns[local.f].name];
// Guard against engines returning a null function reference
// while the mixin component is still compiling (#3457) —
// caching a null ref would write a null member into every
// materialized instance.
if (!IsNull(local.ref)) {
ArrayAppend(local.publicMethods, {
name = local.fns[local.f].name,
ref = local.ref
});
} else {
$warnNullIntegrationPlanRefs("#arguments.path#.#local.componentName# (build)");
}
}
}
ArrayAppend(local.rv, {
Expand Down
84 changes: 84 additions & 0 deletions vendor/wheels/tests/specs/global/IntegrationPlanSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
component extends="wheels.WheelsTest" {

/**
* Covers the cached component-integration plan (#3213, #3457): the plan
* must never carry null function references, because a null ref is copied
* into every materialized model/controller/mapper instance and Lucee 7
* throws a bare NullPointerException when it later enumerates the
* component. $componentIntegrationPlan validates the cached plan once per
* request and rebuilds it in place when it is poisoned.
*/
function run() {

describe("component integration plan", function() {

beforeEach(function() {
g = application.wo;
_planPath = "wheels.model";
_checkKey = LCase(_planPath);
// Make sure a cached plan exists before tests poke at it.
g.$componentIntegrationPlan(_planPath);
_originalPlan = application.wheels.integrationPlans[_planPath];
});

afterEach(function() {
if (IsArray(_originalPlan) && StructKeyExists(application.wheels.integrationPlans, _planPath)) {
application.wheels.integrationPlans[_planPath] = _originalPlan;
}
if (StructKeyExists(request.wheelsIntegrationPlanChecks, _checkKey)) {
StructDelete(request.wheelsIntegrationPlanChecks, _checkKey);
}
});

it("builds a model plan with no null references", function() {
var plan = g.$buildComponentIntegrationPlan("wheels.model");
expect(ArrayLen(plan)).toBeGT(0);
expect(g.$integrationPlanHasNullRefs(plan)).toBeFalse();
});

it("detects entries with missing or null references", function() {
var plan = g.$buildComponentIntegrationPlan("wheels.model");
var poisoned = $shallowCopyPlan(plan);
StructDelete(poisoned[1].publicMethods[1], "ref");
expect(g.$integrationPlanHasNullRefs(poisoned)).toBeTrue();
});

it("rebuilds a cached plan that contains null references", function() {
_originalPlan = application.wheels.integrationPlans[_planPath];
application.wheels.integrationPlans[_planPath] = $shallowCopyPlan(_originalPlan);
StructDelete(application.wheels.integrationPlans[_planPath][1].publicMethods[1], "ref");
// force re-validation within this request
if (StructKeyExists(request.wheelsIntegrationPlanChecks, _checkKey)) {
StructDelete(request.wheelsIntegrationPlanChecks, _checkKey);
}
var plan = g.$componentIntegrationPlan(_planPath);
expect(g.$integrationPlanHasNullRefs(plan)).toBeFalse();
expect(application.wheels.integrationPlans[_planPath]).toBe(plan);
});

});

}

/**
* Shallow-copies an integration plan, copying each publicMethods entry so
* poisoning the copy can never mutate the live cached plan.
*/
private array function $shallowCopyPlan(required array plan) {
var rv = [];
for (var comp in arguments.plan) {
var methods = [];
for (var pm in comp.publicMethods) {
ArrayAppend(methods, {name = pm.name, ref = pm.ref});
}
ArrayAppend(rv, {
instance = comp.instance,
methods = comp.methods,
publicMethods = methods,
fullName = comp.fullName
});
}
return rv;
}

}
Loading