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
2 changes: 1 addition & 1 deletion changelog.d/3063-includeconfig-compile-guard.fixed.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
- A compile-time or runtime error in a `config/*.cfm` file loaded during application start (e.g. an Adobe CF-illegal top-level `var di = injector();` in `config/services.cfm`) is now caught by `$includeConfig()`, logged with the offending file name, and skipped — instead of cascading out of `onApplicationStart` into a masked, application-wide HTTP 500 whose secondary `onError` failure hid the real cause (#3063)
- A compile-time or runtime error in a `config/*.cfm` file loaded during application start (e.g. an Adobe CF-illegal top-level `var di = injector();` in `config/services.cfm`) is now logged and rethrown by `$includeConfig()` as a named `Wheels.ConfigIncludeFailed` error carrying the failing template path and the original engine message (original type/detail preserved in `detail`) — a clear, located, fail-closed boot error in every environment, instead of the old masked, application-wide HTTP 500 whose secondary `onError` failure hid the real cause. An app whose config did not load no longer boots fail-open on framework defaults (#3063)
59 changes: 36 additions & 23 deletions vendor/wheels/Global.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -211,18 +211,22 @@ return local.$wheels;
* Includes a config file like /config/settings.cfm or /config/services.cfm
* during application start, capturing any output it produces.
*
* If the captured output is non-empty — almost always a sign that the file
* is missing a cfscript wrapper, so Lucee/Adobe parse the body as markup
* and any cfscript-style code becomes literal output text that never
* executes — log a clear warning pointing the developer at the most likely
* cause, and discard the output so it doesn't leak into the response of
* whichever request happened to trigger onApplicationStart (which would
* otherwise manifest as raw `var di = ...` text spilling onto every page
* until the next cold restart).
* If the file fails to compile or run, the failure is logged and rethrown
* as a named `Wheels.ConfigIncludeFailed` error that carries the failing
* template path and the original engine message (original type/detail are
* preserved in `detail`). This is deliberate fail-closed behavior in EVERY
* environment: an app whose config did not load must not boot on framework
* defaults and serve traffic. The named error propagates out of
* onApplicationStart by design, and renders on the development error page
* now that onError no longer masks application-start errors.
*
* The function never throws on output; missing-wrapper mistakes are
* recoverable (the file just registered nothing) and a hard error during
* onApplicationStart is worse than a warning + downstream failure.
* If the include succeeds but the captured output is non-empty — almost
* always a sign that the file is missing a cfscript wrapper, so Lucee/Adobe
* parse the body as markup and any cfscript-style code becomes literal
* output text that never executes — log a clear warning pointing the
* developer at the most likely cause, and discard the output so it doesn't
* leak into the response of whichever request happened to trigger
* onApplicationStart.
*
* Note for maintainers: deliberately avoids putting any literal cf-tags
* in this docblock — Lucee 7's tag scanner reads CFC comments before
Expand All @@ -238,28 +242,37 @@ return local.$wheels;
};
// cfformat-ignore-end
} catch (any e) {
// A compile-time or runtime failure in a config template must NOT cascade
// out of onApplicationStart — that aborts application start and surfaces as
// a masked, app-wide HTTP 500 whose onError fallback then fails secondarily,
// hiding the real cause. The canonical trigger is Adobe CF rejecting a
// top-level `var di = injector();` in config/services.cfm (a compile error on
// Adobe, accepted on Lucee), which took down every request at boot (issue
// #3063). Log the offending template by name and continue: the file's
// registrations simply did not run, which is a recoverable, debuggable state
// — exactly the trade-off the output-leak branch below already makes.
// Fail closed: a compile-time or runtime failure in a config template is a
// boot-blocking configuration error in EVERY environment. Booting anyway
// would silently run the app on framework defaults (no DI registrations,
// default settings, …) and serve traffic fail-open — strictly worse than
// a hard stop. Log the offending template, then rethrow a NAMED, located
// error that says what broke, where, and why — instead of the old masked,
// app-wide HTTP 500 whose secondary onError failure hid the real cause
// (the canonical trigger is Adobe CF rejecting a top-level
// `var di = injector();` in config/services.cfm — a compile error on
// Adobe, accepted on Lucee — issue #3063). The throw is unconditional:
// no environment branching, no swallowed path.
try {
writeLog(
file = "wheels",
type = "error",
text = "Wheels: " & arguments.template & " failed to compile or run during"
& " onApplicationStart and was skipped — the application started without"
& " this file's configuration (any registrations in it did not execute)."
& " onApplicationStart — application start was aborted (fail-closed)."
& " Error: " & e.message
);
} catch (any logErr) {
// Logging is best-effort during application start.
}
return;
Throw(
type = "Wheels.ConfigIncludeFailed",
message = "Failed to include config template '" & arguments.template & "': " & e.message,
detail = "Original exception type: " & e.type & "."
& (StructKeyExists(e, "detail") && Len(e.detail) ? " " & e.detail : "")
& " Application start was aborted because this config file could not be"
& " loaded — fix the file and restart (booting without it would run the"
& " application on framework defaults)."
);
}
if (Len(Trim(local.$wheelsConfigOutput))) {
local.preview = Left(Trim(local.$wheelsConfigOutput), 200);
Expand Down
78 changes: 58 additions & 20 deletions vendor/wheels/tests/specs/global/includeConfigSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ component extends="wheels.WheelsTest" {
mapping: "/wheels/tests/_tmp/includeconfig"
};

describe("$includeConfig — config-template failures must not crash app start (issue ##3063)", () => {
describe("$includeConfig — config-template failures fail closed with a named error (issue ##3063)", () => {

beforeEach(() => {
if (DirectoryExists(ctx.baseDir)) {
Expand All @@ -36,29 +36,64 @@ component extends="wheels.WheelsTest" {
StructDelete(request, "$includeConfigSpecRan");
});

it("does not propagate a failure thrown by a config template", () => {
// Reproduces the #3063 class of failure: a config/*.cfm file that
it("rethrows a config-template failure as Wheels.ConfigIncludeFailed", () => {
// Reproduces the ##3063 class of failure: a config/*.cfm file that
// fails to compile or run (on Adobe CF a top-level `var di = ...` in
// config/services.cfm is a compile error) is included during
// onApplicationStart. Before the fix the failure cascaded out of
// $includeConfig, aborting application start and surfacing as a
// masked app-wide HTTP 500. A runtime throw stands in for the
// engine-specific compile error so the regression is portable.
// Referencing an undefined variable throws at runtime on every
// engine — a portable stand-in for the engine-specific compile
// failure, and it avoids nested quotes in the fixture body.
// onApplicationStart. The contract is fail-closed in EVERY
// environment: the failure must surface as a NAMED, located error —
// not the old masked app-wide 500, and not a silent boot on
// framework defaults. A runtime throw (undefined-variable
// reference) stands in for the engine-specific compile error so
// the regression is portable to every CI engine.
FileWrite(
ctx.baseDir & "/badconfig.cfm",
"<cfscript>writeOutput(undefinedConfigVarXyz);</cfscript>"
);
$assert.notThrows(function() {
expect(function() {
ctx.g.$includeConfig(template = ctx.mapping & "/badconfig.cfm");
});
}).toThrow("Wheels.ConfigIncludeFailed");
});

it("names the failing template and preserves the original engine error (##3063 acceptance)", () => {
// The whole point of ##3063: the developer must see WHAT broke,
// WHERE, and WHY. The named error's message must carry the failing
// template path plus the original engine message, and the original
// exception type must survive into detail — a clear, located error
// instead of the masked `Element WHEELS.ENGINEADAPTER is undefined`
// secondary failure that used to hide the real cause.
//
// Cross-engine invariant ##11: `local.X = ...` inside catch does not
// persist on BoxLang, so capture into a shared struct.
FileWrite(
ctx.baseDir & "/badconfig.cfm",
"<cfscript>writeOutput(undefinedConfigVarXyz);</cfscript>"
);
var state = {caught = false, type = "", message = "", detail = ""};
try {
ctx.g.$includeConfig(template = ctx.mapping & "/badconfig.cfm");
} catch (any e) {
state.caught = true;
state.type = e.type;
state.message = e.message;
state.detail = e.detail;
}
expect(state.caught).toBeTrue("expected $includeConfig to rethrow, but it returned normally (fail-open)");
expect(state.type).toBe("Wheels.ConfigIncludeFailed");
// Message names the failing template…
expect(state.message).toInclude(ctx.mapping & "/badconfig.cfm");
// …and carries the original engine message (every engine names the
// undefined variable in its message).
expect(state.message).toInclude("undefinedConfigVarXyz");
// Original exception type is preserved in detail.
expect(Len(state.detail)).toBeGT(0);
expect(state.detail).toInclude("Original exception type:");
});

it("still executes a valid config template body after the fix", () => {
// Guards against the hardening swallowing the happy path: a healthy
// config file must still run, so its registrations take effect.
it("still executes a valid config template body (happy path unchanged)", () => {
// Guards against the fail-closed contract breaking the happy path:
// a healthy config file must still run, so its registrations take
// effect, and must NOT throw.
FileWrite(
ctx.baseDir & "/goodconfig.cfm",
"<cfscript>request.$includeConfigSpecRan = true;</cfscript>"
Expand All @@ -70,10 +105,11 @@ component extends="wheels.WheelsTest" {
expect(request.$includeConfigSpecRan).toBeTrue();
});

it("recovers and keeps loading later config after one file fails", () => {
// A failing file must not poison subsequent $includeConfig calls —
// the environment-specific services.cfm include should still run
// even if the base file blew up.
it("a failing include does not poison a later, healthy include", () => {
// $includeConfig holds no state between calls: after a failing file
// throws (and the caller decides what to do with it), a subsequent
// healthy include must still work. Note this is NOT log-and-continue
// — the first call throws; app start would normally abort there.
FileWrite(
ctx.baseDir & "/badconfig.cfm",
"<cfscript>writeOutput(undefinedConfigVarXyz);</cfscript>"
Expand All @@ -82,8 +118,10 @@ component extends="wheels.WheelsTest" {
ctx.baseDir & "/goodconfig.cfm",
"<cfscript>request.$includeConfigSpecRan = true;</cfscript>"
);
$assert.notThrows(function() {
expect(function() {
ctx.g.$includeConfig(template = ctx.mapping & "/badconfig.cfm");
}).toThrow("Wheels.ConfigIncludeFailed");
$assert.notThrows(function() {
ctx.g.$includeConfig(template = ctx.mapping & "/goodconfig.cfm");
});
expect(StructKeyExists(request, "$includeConfigSpecRan")).toBeTrue();
Expand Down
Loading