Skip to content

Commit 51edf9d

Browse files
authored
fix(public): isolate helpers include from $init promote scan
Refs #3379. Isolate Public helpers include from $scanAndPromoteIncludedGlobals so Adobe 2023 CommandBox first-boot does not EmptyStack.
1 parent c64f6a9 commit 51edf9d

4 files changed

Lines changed: 209 additions & 17 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- `Public.$init` no longer nests the `/wheels/public/helpers.cfm` include in the same method as `$scanAndPromoteIncludedGlobals()`. That nest threw `EmptyStackException` from Adobe `popSuperScope` on the first request after a CommandBox cold start on Adobe CF 2023; the 4.0.6 helper-promote onto `this` is unchanged (#3379)

‎vendor/wheels/Public.cfc‎

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,52 @@ component output="false" displayName="Internal GUI" extends="wheels.Global" {
44
* Internal function.
55
*/
66
public struct function $init() {
7-
include "/wheels/public/helpers.cfm";
8-
9-
// The include above declares its UDFs into `variables` only — they never
10-
// reach `this` on Lucee 6, Adobe 2023 or Adobe 2025 (Lucee 7 and BoxLang
11-
// do promote them, which is why the split stayed invisible). Every helper
12-
// in helpers.cfm is declared `public`, and the framework's own views reach
13-
// them through `variables`, so the divergence only bites an external
14-
// caller — `CreateObject("component", "wheels.Public").$init().$$findMatchingRoutes(…)`
15-
// threw "has no function with name" on three of five engines (##3302).
16-
//
17-
// Same problem, same fix as the `/app/global/functions.cfm` include in
18-
// `Global.cfc`'s pseudo-constructor. Call the raw scan rather than
19-
// `$promoteIncludedGlobalsToThis()`: that wrapper memoizes its promote
20-
// list per class in application scope, and the entry for `wheels.Public`
21-
// is written by the pseudo-constructor *before* this include runs — so the
22-
// memoized path would replay a stale, pre-include key list and promote
23-
// nothing. This is the dev-only GUI component, not a request hot path.
7+
// The helpers include MUST live in its own method. 4.0.6 added
8+
// `$scanAndPromoteIncludedGlobals()` immediately after a raw `include`
9+
// in this same `$init` body (##3302 / 6bff054). That nests Adobe's
10+
// include page-context with a parent-class method call (Global's
11+
// promote scan). On the first request after a CommandBox cold start,
12+
// Adobe CF 2023's `UDFMethod.invoke` cleanup then calls
13+
// `NeoPageContext.popSuperScope` against an empty stack —
14+
// EmptyStackException at onapplicationstart.cfc:409
15+
// (`$createObjectFromRoot` → Public.$init). A later request succeeds
16+
// because helpers.cfm is already compiled. 4.0.5 `$init` only
17+
// included and returned, which is why discarding 4.0.6 files cleared
18+
// it. Isolate the include so its page-context pops before the promote
19+
// scan runs; keep the raw scan (not the memoized wrapper) so the
20+
// ##3302 `this`-visibility contract stays.
21+
$includePublicHelpers();
2422
$scanAndPromoteIncludedGlobals();
2523

2624
return this;
2725
}
2826

27+
/**
28+
* Includes `/wheels/public/helpers.cfm` in its own UDF frame so Adobe's
29+
* include page-context is popped before `$init` calls the parent-class
30+
* promote scan. Do not inline this `include` back into `$init` — that
31+
* nest is the 4.0.6 first-boot EmptyStackException on Adobe CF 2023.
32+
*
33+
* The include declares its UDFs into `variables` only — they never
34+
* reach `this` on Lucee 6, Adobe 2023 or Adobe 2025 (Lucee 7 and BoxLang
35+
* do promote them, which is why the split stayed invisible). Every helper
36+
* in helpers.cfm is declared `public`, and the framework's own views reach
37+
* them through `variables`, so the divergence only bites an external
38+
* caller — `CreateObject("component", "wheels.Public").$init().$$findMatchingRoutes(…)`
39+
* threw "has no function with name" on three of five engines (##3302).
40+
*
41+
* Same problem, same fix as the `/app/global/functions.cfm` include in
42+
* `Global.cfc`'s pseudo-constructor. `$init` calls the raw scan rather than
43+
* `$promoteIncludedGlobalsToThis()`: that wrapper memoizes its promote
44+
* list per class in application scope, and the entry for `wheels.Public`
45+
* is written by the pseudo-constructor *before* this include runs — so the
46+
* memoized path would replay a stale, pre-include key list and promote
47+
* nothing. This is the dev-only GUI component, not a request hot path.
48+
*/
49+
public void function $includePublicHelpers() {
50+
include "/wheels/public/helpers.cfm";
51+
}
52+
2953
/**
3054
* Returns true unless the current application environment is `development`
3155
* (fail closed: a missing `application.wheels` struct or `environment` key
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Order-trace fixture for Public.$init's include-then-promote contract.
3+
*
4+
* Extends wheels.Public and records when `$includePublicHelpers` starts,
5+
* when it returns, and when `$scanAndPromoteIncludedGlobals` runs. Specs
6+
* reset the trace after construction (Global's pseudo-constructor also
7+
* calls the promote scan) and then invoke `$init`.
8+
*
9+
* On the 4.0.6 nest (`include` + promote in the same `$init` body) the
10+
* include helper is never called, so the trace is only `promote`. After
11+
* the include is extracted, the trace is include-start, include-return,
12+
* promote — prove the scan runs after the include method returns.
13+
*/
14+
component extends="wheels.Public" {
15+
16+
public void function $resetInitTrace() {
17+
variables.$initTrace = [];
18+
}
19+
20+
public array function $getInitTrace() {
21+
if (!StructKeyExists(variables, "$initTrace") || !IsArray(variables.$initTrace)) {
22+
return [];
23+
}
24+
return variables.$initTrace;
25+
}
26+
27+
public void function $includePublicHelpers() {
28+
if (!StructKeyExists(variables, "$initTrace") || !IsArray(variables.$initTrace)) {
29+
variables.$initTrace = [];
30+
}
31+
ArrayAppend(variables.$initTrace, "include-start");
32+
super.$includePublicHelpers();
33+
ArrayAppend(variables.$initTrace, "include-return");
34+
}
35+
36+
public array function $scanAndPromoteIncludedGlobals() {
37+
if (!StructKeyExists(variables, "$initTrace") || !IsArray(variables.$initTrace)) {
38+
variables.$initTrace = [];
39+
}
40+
ArrayAppend(variables.$initTrace, "promote");
41+
return super.$scanAndPromoteIncludedGlobals();
42+
}
43+
44+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Regression gate for the 4.0.6 first-boot EmptyStackException on
3+
* Adobe CF 2023 + CommandBox (issue ##3379, separate from the torn-down
4+
* application.wo / onError String[] stacks on that same issue).
5+
*
6+
* Field stack: onapplicationstart.cfc:409 `$createObjectFromRoot` →
7+
* Public.$init → Adobe `NeoPageContext.popSuperScope` EmptyStack. 4.0.5
8+
* Public.$init is include helpers + return only. 4.0.6 and develop call
9+
* `$scanAndPromoteIncludedGlobals()` (a parent-class method on Global)
10+
* immediately after the raw `include` in the same `$init` body. Adobe's
11+
* first compile of helpers.cfm after a cold start unbalances the
12+
* super-scope stack; a later request succeeds.
13+
*
14+
* This VM cannot run Adobe CF 2023 + CommandBox, so the field EmptyStack
15+
* cannot be reproduced here. What CI can prove:
16+
* 1. `$init` does not throw on the LuCLI engine.
17+
* 2. The ##3302 promote still lands helpers.cfm UDFs on `this`.
18+
* 3. The promote scan runs after `$includePublicHelpers` returns — not
19+
* nested inside the same method as the raw include.
20+
* 4. `$init`'s own body contains no raw `include` statement, so the
21+
* nest cannot silently return.
22+
*/
23+
component extends="wheels.WheelsTest" {
24+
25+
function run() {
26+
27+
var ctx = {fixturePath: "wheels.tests._assets.global.PublicInitTraceFixture"};
28+
29+
describe("Public.$init include/promote un-nest (issue ##3379 first-boot EmptyStack)", () => {
30+
31+
it("Public.$init does not throw", () => {
32+
var state = {threw = false, message = ""};
33+
try {
34+
var publicCfc = CreateObject("component", "wheels.Public");
35+
publicCfc.$init();
36+
} catch (any e) {
37+
state.threw = true;
38+
state.message = e.message;
39+
}
40+
expect(state.threw).toBeFalse(
41+
"Public.$init threw: " & state.message
42+
);
43+
});
44+
45+
it("still promotes helpers.cfm functions onto this (##3302 contract)", () => {
46+
var publicCfc = CreateObject("component", "wheels.Public");
47+
publicCfc.$init();
48+
expect(StructKeyExists(publicCfc, "$$findMatchingRoutes")).toBeTrue(
49+
"expected $$findMatchingRoutes from helpers.cfm on this after $init"
50+
);
51+
expect(IsCustomFunction(publicCfc["$$findMatchingRoutes"])).toBeTrue();
52+
expect(StructKeyExists(publicCfc, "pageHeader")).toBeTrue(
53+
"expected pageHeader from helpers.cfm on this after $init"
54+
);
55+
expect(IsCustomFunction(publicCfc.pageHeader)).toBeTrue();
56+
});
57+
58+
it("the $createObjectFromRoot boot path still returns a promoted Public instance", () => {
59+
var publicCfc = application.wo.$createObjectFromRoot(
60+
path = "wheels",
61+
fileName = "Public",
62+
method = "$init"
63+
);
64+
expect(IsObject(publicCfc)).toBeTrue();
65+
expect(StructKeyExists(publicCfc, "$$findMatchingRoutes")).toBeTrue();
66+
expect(IsCustomFunction(publicCfc["$$findMatchingRoutes"])).toBeTrue();
67+
});
68+
69+
it("promote runs after $includePublicHelpers returns, not mid-include", () => {
70+
var fixture = CreateObject("component", ctx.fixturePath);
71+
fixture.$resetInitTrace();
72+
fixture.$init();
73+
var trace = fixture.$getInitTrace();
74+
expect(ArrayLen(trace)).toBe(
75+
3,
76+
"expected [include-start, include-return, promote], got [" & ArrayToList(trace) & "]"
77+
);
78+
expect(trace[1]).toBe("include-start");
79+
expect(trace[2]).toBe("include-return");
80+
expect(trace[3]).toBe("promote");
81+
});
82+
83+
it("$init body contains no raw include (structural nest guard)", () => {
84+
var filePath = ExpandPath("/wheels/Public.cfc");
85+
var content = FileRead(filePath);
86+
var fileLines = ListToArray(content, Chr(10), true);
87+
var inInit = false;
88+
var offenders = [];
89+
var lineNumber = 0;
90+
for (var rawLine in fileLines) {
91+
lineNumber++;
92+
var trimmed = Trim(Replace(rawLine, Chr(13), "", "all"));
93+
if (ReFindNoCase("function\s+\$init\s*\(", trimmed)) {
94+
inInit = true;
95+
continue;
96+
}
97+
if (inInit && ReFindNoCase("function\s+\$includePublicHelpers\s*\(", trimmed)) {
98+
break;
99+
}
100+
if (inInit && ReFindNoCase("(public|private|package|remote)?\s*(any|void|struct|array|string|boolean|numeric|query|date)?\s*function\s+", trimmed)) {
101+
break;
102+
}
103+
if (!inInit) {
104+
continue;
105+
}
106+
if (Left(trimmed, 2) == "//" || Left(trimmed, 1) == "*" || Left(trimmed, 2) == "/*") {
107+
continue;
108+
}
109+
if (ReFindNoCase("^\s*include\s+", trimmed)) {
110+
ArrayAppend(offenders, "raw include at line " & lineNumber);
111+
}
112+
}
113+
expect(inInit).toBeTrue("expected to find Public.$init in Public.cfc");
114+
expect(ArrayLen(offenders)).toBe(
115+
0,
116+
"Public.$init must not contain a raw include (nest with promote scan): " & ArrayToList(offenders)
117+
);
118+
});
119+
120+
});
121+
}
122+
123+
}

0 commit comments

Comments
 (0)