fix: make URL environment switching work through the app reload restart redirect - #3036
Conversation
…rt redirect The only path that restarts a stock app is public/Application.cfc's reload gate: applicationStop() + a redirect built by $buildRedirectUrl(), which stripped reload, password, and lock from the query string. The framework's environment switch (wheels/events/onapplicationstart.cfc) needs URL.reload and URL.password present on the request that starts the new application, so ?reload=<environment> was a silent no-op through the stock flow. Three cooperating changes, applied to all four same-lineage copies (CLI app template, repo demo app, starter-app and tweet examples): 1. $buildRedirectUrl() preserves reload + password (still strips lock) when the reload value is an environment switch that can actually apply (non-boolean, non-empty, password supplied, reloadPassword configured). Plain ?reload=true keeps the strip-everything behavior. 2. The configured reloadPassword is handed across the applicationStop() boundary via a single-use, expiring server-scope entry consumed by onApplicationStart(). Without it the switch can never apply: the framework reads the password BEFORE config/settings.cfm is loaded, via carryover from the live application scope that applicationStop() destroys (verified live on Lucee 7 - preserving the parameters alone produced an endless 302 chain with the environment stuck). 3. onRequestStart() breaks the restart loop: once the requested environment is active, the gate is skipped and the request served normally. Trade-off: ?reload=<current-environment> is a no-op; use ?reload=true for a same-environment restart. A structural parity spec pins all three parts across the four copies. Fixes #3030 Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR: This PR makes the documented ?reload=<environment>&password=... URL environment switch actually work through the stock app template's applicationStop() reload flow, fixing #3030 across all four same-lineage public/Application.cfc copies, with a structural parity spec, a changelog fragment, and thorough live verification. The mechanism is sound — I traced the $init(this) copy (vendor/wheels/events/onapplicationstart.cfc:7-9) into the reloadPassword carryover (lines 17-32) and it works exactly as the PR describes — and the loop-safety conditions hold in every single-request sequence I could construct. However, the single-use handoff has a consumption race under concurrent traffic that turns the switch into repeated full restarts on a busy app, so my verdict is request changes for that one finding. Everything else I probed (spec idioms, guard interactions, commit hygiene) survived scrutiny or is explicitly sibling-owned by #3031.
Correctness
Handoff consumption race: any first-request-after-stop consumes the single-use entry, so under concurrent traffic the switch degrades into repeated full restarts.
public/Application.cfc:119-121 (and the same lines in the other three copies) consume the handoff on whatever request happens to boot the new application:
if (StructKeyExists(server, local.handoffKey)) {
local.handoff = server[local.handoffKey];
StructDelete(server, local.handoffKey);The race: $handleRestartAppRequest() writes the handoff and calls applicationStop() (public/Application.cfc:447-453), then the 302 round-trips through the client. In that window (one client RTT), any unrelated request boots the new application first, consumes/deletes the handoff, and — carrying no url.reload — never applies the switch; config/environment.cfm sets the old environment. The redirected request then arrives at an already-running app where application.wheels.environment != url.reload, so the loop-break is false, the gate re-fires (the password still matches the freshly-loaded config), and the whole cycle repeats: another applicationStop(), another handoff, another race. The exclusive reloadLock doesn't serialize this — it's released when location() aborts the request, and the interloper's onApplicationStart isn't guarded by it.
Convergence requires the redirected request to win the race once. On a quiet dev box it always does (your live verification: "exactly 1 redirect"). On an app receiving concurrent traffic, the redirect frequently loses, so the admin's browser walks toward its ~20-redirect cap while the app absorbs a full restart per lost round — and the switch can ultimately fail with ERR_TOO_MANY_REDIRECTS having restarted the app many times. This race is newly introduced by this PR: the pre-existing ?reload=true flow has the same first-boot-wins shape, but there no state crosses the boundary so it never mattered.
Suggested fix: only consume the handoff when the booting request is actually an environment-switch request — the redirect always carries reload=<env>, interlopers essentially never do:
local.handoffKey = "$wheelsReloadPasswordHandoff_" & this.name;
if (StructKeyExists(server, local.handoffKey)) {
local.handoff = server[local.handoffKey];
if (
IsStruct(local.handoff) && StructKeyExists(local.handoff, "expiresAt")
&& DateCompare(Now(), local.handoff.expiresAt) >= 0
) {
// expired — clean up regardless of who booted us
StructDelete(server, local.handoffKey);
} else if (StructKeyExists(url, "reload") && !IsBoolean(url.reload)) {
StructDelete(server, local.handoffKey);
if (IsStruct(local.handoff) && StructKeyExists(local.handoff, "reloadPassword")) {
this.wheels.reloadPassword = local.handoff.reloadPassword;
}
}
}Reading URL during onApplicationStart has in-repo prior art — the framework's own switch code does it from the same context (vendor/wheels/events/onapplicationstart.cfc:173-179). Honest trade-off to weigh: this extends the password's worst-case server-scope lifetime from "next boot" (~ms) to the 1-minute TTL when a switch is abandoned mid-redirect; given the PR's own trust-domain argument for the server scope, that seems acceptable, but it's your call — an alternative is to keep unconditional consumption and document that switches on busy apps may take multiple attempts. Either way, please update ReloadEnvironmentSwitchParitySpec.cfc's consume-side assertions to pin whichever shape lands (the current StructDelete(server, local.handoffKey) and expiry assertions would still pass, but the spec should also pin the url.reload consumption guard if you adopt it).
For the record, things I specifically probed here and could not fault:
- The handoff key's
this.nameis stable across requests despitethis.name = createUUID()atpublic/Application.cfc:7, becauseconfig/app.cfm(included later in the pseudo-constructor) overrides it with a fixed name in all four lineages ("wheels-dev","{{appName}}","starterApp","tweet"). - The carryover mechanism:
$init(this)doesapplication[key] = keys[key]for everythis-scope key (vendor/wheels/events/onapplicationstart.cfc:7-9) before theapplication.wheels.reloadPasswordcarryover read (lines 17-21), so seedingthis.wheels.reloadPasswordworks exactly as described. - Loop-safety of every single-request sequence:
?reload=(empty), wrong password, no password configured,?reload=true&foo=bar, and same-environment no-op all behave as the PR claims — the preserve condition (Len(url.reload)+ supplied password + configured non-emptyreloadPassword) and the loop-break compose correctly, including the expired-handoff case, which self-heals in one extra cycle. - The cold-start bypass of
allowEnvironmentSwitchViaUrl=false(no carryover means the guard atvendor/wheels/events/onapplicationstart.cfc:222is inert, and the prod-default guard at lines 346-352 runs after the switch already applied) is real but explicitly owned by open issue #3031, which this PR correctly leaves to the vendor-side fix.
Docs
Nit, non-blocking: the ?reload=<current-environment> no-op trade-off is documented in the code comments and the changelog fragment, but not in the user-facing guides. Since web/sites/guides/src/content/docs/v4-0-0/.../environments-and-configuration.mdx is where users learn the switch syntax, a one-line note there ("to restart without switching, use ?reload=true") would prevent confusion when a same-environment ?reload=development silently does nothing.
Tests
No findings — noting for the record: ReloadEnvironmentSwitchParitySpec.cfc is structural (regex over source) rather than behavioral, but that follows the established mold for Application.cfc template parity (ApplicationCfcInjectorAssignmentSpec.cfc, same folder, same IIFE loop-capture and raw-substring idioms that already pass the full engine matrix), runtime-testing an applicationStop() flow inside the suite is infeasible, and the spec is proven to bite (reverting one copy produces exactly that copy's 3 failures per the PR body). The ## escaping in spec strings is correct throughout.
Commits
No findings: single commit, header fix: make URL environment switching work through the app reload restart redirect (81 chars, valid type, not ALL-CAPS), DCO sign-off present, changelog fragment changelog.d/3030-reload-envswitch-redirect.fixed.md uses the fragment system correctly.
…switch path The #3030 fix preserved reload+password and handed the reloadPassword across applicationStop() for environment switches, but checked only reload/password on the preserve and handoff conditions. It never consulted application.wheels.allowEnvironmentSwitchViaUrl, which IS available pre-restart. After applicationStop() the framework cannot enforce the flag: the switch in wheels/events/onapplicationstart.cfc reads only the password, the revert needs oldEnvironment carryover that never exists on a cold start, and the production/testing/maintenance auto-disable runs only after the switch has already applied (and not at all once the environment has become development). Net effect: a production app became switchable into development with only the reload password — verified live on Lucee 7 (with the guard removed, a production app's /wheels/info flips from 404 to 200 after ?reload=development). Add the flag check to both the preserve () and handoff () conditions in all four same-lineage public/Application.cfc copies. A disallowed switch now degrades to the safe strip-all plain restart. A missing flag counts as allowed, matching the framework's carryover default, so plain ?reload=true and allowed switches are unaffected. Extend ReloadEnvironmentSwitchParitySpec with a fourth assertion pinning the guard on both paths across all four copies. Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR: Re-review at 75cd0fa4d. The new commit (fix: enforce allowEnvironmentSwitchViaUrl on the reload redirect env-switch path) is a genuine and well-executed hardening fix — I independently verified that without it a production app was switchable into development with only the reload password, and that the guard now closes that on the template path (details below). However, the one blocking finding from my previous review (against 3e98b50d2) is unaddressed at this SHA: the handoff consume block in onApplicationStart() is byte-identical, so the consumption race under concurrent traffic still stands. Verdict remains request changes for that single carried-over Correctness finding; everything in the new commit itself survived scrutiny.
Correctness
[Carried over, still open] Handoff consumption race: any first-request-after-stop consumes the single-use entry, so under concurrent traffic the switch degrades into repeated full restarts.
public/Application.cfc:118-130 (and the same block in the other three copies) is unchanged from the previously reviewed SHA — commit 75cd0fa4d only touches the $handleRestartAppRequest() and $buildRedirectUrl() conditions:
local.handoffKey = "$wheelsReloadPasswordHandoff_" & this.name;
if (StructKeyExists(server, local.handoffKey)) {
local.handoff = server[local.handoffKey];
StructDelete(server, local.handoffKey);Any request that happens to boot the new application consumes the handoff. Between applicationStop() and the redirected request's arrival (one client RTT), an unrelated request can boot the app first, consume/delete the handoff, and — carrying no url.reload — never apply the switch. The redirected request then arrives at an already-running app where application.wheels.environment != url.reload, the loop-break is false, the gate re-fires, and the cycle repeats: another restart, another handoff, another race. On a quiet dev box the redirect always wins (matching the PR's live verification); on an app receiving concurrent traffic the admin's browser walks toward its redirect cap while the app absorbs a full restart per lost round. I re-verified this dynamic against the post-75cd0fa4d code: the new flag guard only governs whether the handoff is written — for an allowed switch the boot-consumption race is identical.
The suggested fix from the prior review still applies: consume the handoff only when the booting request actually carries a non-boolean url.reload (the redirect always does; interlopers essentially never do), with an unconditional delete for expired entries. Reading URL during onApplicationStart has in-repo prior art at vendor/wheels/events/onapplicationstart.cfc:182. Whichever shape lands, please extend ReloadEnvironmentSwitchParitySpec.cfc's consume-side assertions to pin it.
Security
No findings — affirmatively verified the new commit's claims rather than taking them on trust:
- The guard is present on both paths in all four copies (
public/Application.cfc:455in$handleRestartAppRequest,:507in$buildRedirectUrl; grep count is exactly 2 per copy). - The framework's auto-disable really is visible to the template pre-restart:
application.wheels = application.$wheels(vendor/wheels/events/onapplicationstart.cfc:422) and the production/testing/maintenance auto-disable writesapplication.$wheels.allowEnvironmentSwitchViaUrl = false(lines 346–352), so on a stock production app the live flag isfalse, the parameters are stripped, and the request degrades to the safe strip-all plain restart. - "Missing flag counts as allowed" matches the framework default (
application.$wheels.allowEnvironmentSwitchViaUrl = true, line 144), so plain?reload=trueand allowed switches are unaffected. - No new loop hazard: a disallowed switch strips everything, so the redirected request cannot re-trigger the gate.
One sibling-owned observation, non-blocking: because the vendor's auto-disable detects an explicit override by value comparison (local.envSwitchDefault captured at onapplicationstart.cfc:308 is true on a cold start, so an explicit set(allowEnvironmentSwitchViaUrl=true) is indistinguishable from the default and lines 346–352 still disable it), the documented production override only survives a warm ?reload=true cycle, not a cold start — and consequently a testing → dev switch through this PR's flow actually lands in development via the degraded plain restart + config/environment.cfm, not via the switch path. The template guard honoring the live flag is the right call here; the detection weakness is vendor-side and owned by #3031.
Tests
No findings. The extended spec's fourth assertion (ArrayLen(reMatch(flagGuard, content)) >= 2) matches the real guard shape across newlines in all four copies, and the Lucee 7 + SQLite (LuCLI) check is green at this SHA, so the extended spec compiles and passes in CI. Minor note for the upcoming race fix: the >= 2 count can't distinguish which function carries each occurrence — fine for now given the structural-spec mold, but worth anchoring if the spec grows further.
Docs
Carried over, non-blocking: the ?reload=<current-environment> no-op trade-off is still documented only in code comments and the changelog fragment, not in web/sites/guides/src/content/docs/v4-0-0/.../environments-and-configuration.mdx where users learn the switch syntax. A one-line "to restart without switching, use ?reload=true" would prevent confusion. Also a trivial nit: the PR body's "How (three cooperating parts)" and "12 assertions-groups" predate the new commit (the spec now pins four parts / 16 tests) — worth refreshing when you push the race fix.
Commits
Both commits pass commitlint (valid fix type, headers ≤ 100 chars, not ALL-CAPS, DCO sign-off present; the Validate Commit Messages check is green). One cosmetic nit in 75cd0fa4d's body: "Add the flag check to both the preserve () and handoff () conditions" — the function names inside the parentheses were dropped, presumably $buildRedirectUrl() and $handleRestartAppRequest(). Not a lint failure, just noting it since the body is otherwise exemplary.
…3053) (#3057) PR #3036 added unscoped URL-scope reads (StructKeyExists(url, "reload"), url.reload, ...) inside $buildRedirectUrl(), which had always declared a string local named url. On Adobe CF unscoped name resolution finds the local before the URL scope, so every password-gated reload, URL environment switch, and 'wheels reload' dereferenced a string and returned HTTP 500 before applicationStop() — CLAUDE.md anti-pattern #11 (reserved scope names). Lucee was unaffected because the url scope always wins there. - Rename local.url to local.redirectPath in all four same-lineage copies of public/Application.cfc (repo demo app, CLI app template, starter-app and tweet examples); full-file audit found no other reserved-scope locals or arguments. - ReloadEnvironmentSwitchParitySpec gains a fifth it-block per copy that pins the rename and fails if any local/var named url reappears in these files (line-anchored scan, comment lines skipped). - Probe-gap closure: tools/ci/smoke-env.sh probe 6 asserts an authorized reload (correct password) answers 302, not 5xx/200. Opt-in via SMOKE_RELOAD_PASSWORD; SKIPs when unset. Wired into both smoke-env.yml matrix legs with the CI app's reloadPassword. This is the probe that would have caught #3053 on an Adobe leg. Fixes #3053 Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…RL env switches persist (#3058) The redirectAfterReload block in wheels/events/onapplicationstart.cfc called a bare $location() that does not resolve in that mixin-free component, so the post-switch cold start threw 'No matching function [$LOCATION] found', 500'd the request, and the environment switch into production/maintenance silently reverted (those two environments auto-enable redirectAfterReload via events/init/orm.cfm). Latent since the Dec 2024 lifecycle restructure (efb0b1e); became reachable when #3036 made the restart redirect preserve the reload/password parameters. Fixing the call resolution alone is not enough: cflocation aborts the request while onApplicationStart is still running and the engine then discards the half-started application, reverting the switch anyway (verified live on Lucee 7). The block now stashes the stripped URL on the request scope and EventMethods.$runOnRequestStart — same request, after the new application has been persisted — performs the $location() redirect, where it resolves via the Global.cfc inheritance chain. OnAppStartBareHelperGuardSpec pins all three legs: no bare $-helper calls in the mixin-free onapplicationstart.cfc (line-anchored, comment-skipping scan), the producer/consumer deferral pair, and $location resolution on EventMethods. Fixes #3054 Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…rder and env-switch behavior (#3067) Audit-driven corrections for core-concepts/environments-and-configuration.mdx: - Fix config load order: app.cfm (pseudo-constructor, every request) -> environment.cfm -> framework defaults -> settings.cfm -> config/<env>/settings.cfm -> services.cfm -> routes.cfm - app.cfm executes on every request, not once per app start - Scaffold hard-codes set(environment="development"); WHEELS_ENV is not read by environment.cfm or the wheels CLI; document the two edits (env() read + remove scaffold .env line) required to drive it - Remove set(environment=...) from the settings.cfm example and warn about the half-switched-app failure mode - Replace fictional testing/production "typical settings" rows with the real framework defaults; move log rotation / HTTPS to web server / SecurityHeaders middleware - Add runtime environment switching section: ?reload=<env>&password=..., allowEnvironmentSwitchViaUrl gate semantics (#3036/#3038/#3058) - Cite open issues #3059, #3060, #3062 for current caveats Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
What
?reload=<environment>&password=...— the documented URL environment switch — was a silent no-op on every stock app. The only restart path ispublic/Application.cfc's reload gate (applicationStop()+ redirect via$buildRedirectUrl()), which strippedreload/password/lockfrom the redirect, while the framework's switch code (vendor/wheels/events/onapplicationstart.cfc:171-191) needsURL.reload+URL.passwordpresent on the request that starts the new application. The two halves were written against incompatible assumptions (issue #3030).Fixed in all four same-lineage copies:
cli/lucli/templates/app/public/Application.cfc(whatwheels newships),public/Application.cfc(repo demo app),examples/starter-app/...,examples/tweet/....How (three cooperating parts)
$buildRedirectUrl()preservesreload+password(still stripslock) when the reload value is an environment switch that can actually apply: non-boolean, non-empty, password supplied, non-emptyreloadPasswordconfigured. Plain?reload=truekeeps the strip-everything behavior, byte-for-byte (location: /?foo=barfor?reload=true&password=X&foo=bar).applicationStop()boundary — a necessary addition beyond the issue's proposed fix. The framework's switch block runs beforeconfig/settings.cfmis loaded and reads the password via carryover from the liveapplication.wheels— whichapplicationStop()destroys. Verified live on Lucee 7: with only the preserve+loop-break changes, the redirected cold start could never verify the password, the environment stayeddevelopment, and the chain 302'd forever.$handleRestartAppRequest()now stashes the app's own configuredreloadPassword(already constant-time-verified against the request by the gate) in a single-use, 1-minute-expiry server-scope entry;onApplicationStart()consumes it intothis.wheels.reloadPassword, which$initcopies intoapplication.wheelsright before its carryover check. Server scope is reachable only by code on the engine — the same trust domain asconfig/settings.cfmitself. No framework files touched.url.reloadis non-boolean andapplication.wheels.environmentalready equals it, the switch has been applied by the restart this redirect came from — skipapplicationStop()and serve normally. Required becauseredirectAfterReloaddefaults tofalse. Documented trade-off (code comment + here):?reload=<current-environment>is a no-op; use?reload=truefor a same-environment restart.Loop-safety of the preserve condition: parameters are only preserved when the switch can succeed (configured non-empty
reloadPassword+ supplied password).?reload=(empty), no-password-configured, and wrong-password cases all keep today's behavior — verified live below.Out of scope (sibling-owned)
vendor/wheels/events/onapplicationstart.cfcandvendor/wheels/Dispatch.cfcuntouched — events: explicit set(allowEnvironmentSwitchViaUrl=true) is indistinguishable from the default — documented override impossible #3031 owns the explicit-override/prod-guard trapdoor. The back-switch fromtestingworks here precisely because the post-stop cold start carries no staleoldEnvironment/allowEnvironmentSwitchViaUrlstate.web/sites/guides/.../security-hardening.mdx,production-config.mdx,environments-and-configuration.mdx): they describe the intended behavior this PR makes real; nothing promises the old broken behavior, so no wording changes needed.Tests
New structural parity spec
vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc(mold:ApplicationCfcInjectorAssignmentSpec.cfc) pins all three parts across the four copies — 12 assertions-groups, and proven to bite: reverting one copy flips the suite to 417 with exactly that copy's 3 failures.Lucee 7 + SQLite docker harness (
wheels-test-lucee7:v1.0.0), full core suite:The 12 failures are the known pre-existing
wheels.tests.specs.internal.testClientSpeccontainer artifacts in both runs — zero new failures, +12 new passes from the parity spec.Live verification (Lucee 7 demo app, staged
set(reloadPassword="testpw"); set(allowEnvironmentSwitchViaUrl=true);):RED (unmodified code):
GET /?reload=testing&password=testpw→302, location: /(params stripped) →/wheels/info?format=jsonstillenvironment=development.GREEN (this PR):
dev → testing:302, location: /?reload=testing&password=testpw→ chain terminates in exactly 1 redirect (curl -L --max-redirs 3:final=404 redirects=1) →/wheels/infoflips 200 JSON → 404 (public component disabled in testing).testing → dev: 1 redirect,final=200→/wheels/info200 JSON,environment=development.?reload=true&password=testpw&foo=bar→302, location: /?foo=bar(unchanged).?reload=developmentwhile in development →200directly (no restart — the documented no-op).200, no restart, env unchanged.?reload=&password=testpw→302, location: /(strip, no loop).Fixes #3030
🤖 Generated with Claude Code