diff --git a/changelog.d/integration-plan-null-refs.fixed.md b/changelog.d/integration-plan-null-refs.fixed.md new file mode 100644 index 000000000..7468a1a91 --- /dev/null +++ b/changelog.d/integration-plan-null-refs.fixed.md @@ -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. diff --git a/vendor/wheels/global/objects.cfm b/vendor/wheels/global/objects.cfm index 0c9dd8705..c89e80bce 100644 --- a/vendor/wheels/global/objects.cfm +++ b/vendor/wheels/global/objects.cfm @@ -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. + } } @@ -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, { diff --git a/vendor/wheels/tests/specs/global/IntegrationPlanSpec.cfc b/vendor/wheels/tests/specs/global/IntegrationPlanSpec.cfc new file mode 100644 index 000000000..ebff676f5 --- /dev/null +++ b/vendor/wheels/tests/specs/global/IntegrationPlanSpec.cfc @@ -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; + } + +}