Skip to content

fix: make URL environment switching work through the app reload restart redirect - #3036

Merged
bpamiri merged 2 commits into
developfrom
peter/issue-3030-reload-redirect-envswitch
Jun 11, 2026
Merged

fix: make URL environment switching work through the app reload restart redirect#3036
bpamiri merged 2 commits into
developfrom
peter/issue-3030-reload-redirect-envswitch

Conversation

@bpamiri

@bpamiri bpamiri commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

What

?reload=<environment>&password=... — the documented URL environment switch — was a silent no-op on every stock app. The only restart path is public/Application.cfc's reload gate (applicationStop() + redirect via $buildRedirectUrl()), which stripped reload/password/lock from the redirect, while the framework's switch code (vendor/wheels/events/onapplicationstart.cfc:171-191) needs URL.reload + URL.password present 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 (what wheels new ships), public/Application.cfc (repo demo app), examples/starter-app/..., examples/tweet/....

How (three cooperating parts)

  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, non-empty reloadPassword configured. Plain ?reload=true keeps the strip-everything behavior, byte-for-byte (location: /?foo=bar for ?reload=true&password=X&foo=bar).
  2. Reload-password handoff across the applicationStop() boundarya necessary addition beyond the issue's proposed fix. The framework's switch block runs before config/settings.cfm is loaded and reads the password via carryover from the live application.wheels — which applicationStop() destroys. Verified live on Lucee 7: with only the preserve+loop-break changes, the redirected cold start could never verify the password, the environment stayed development, and the chain 302'd forever. $handleRestartAppRequest() now stashes the app's own configured reloadPassword (already constant-time-verified against the request by the gate) in a single-use, 1-minute-expiry server-scope entry; onApplicationStart() consumes it into this.wheels.reloadPassword, which $init copies into application.wheels right before its carryover check. Server scope is reachable only by code on the engine — the same trust domain as config/settings.cfm itself. No framework files touched.
  3. Loop-break in the reload gate: when url.reload is non-boolean and application.wheels.environment already equals it, the switch has been applied by the restart this redirect came from — skip applicationStop() and serve normally. Required because redirectAfterReload defaults to false. Documented trade-off (code comment + here): ?reload=<current-environment> is a no-op; use ?reload=true for 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.cfc and vendor/wheels/Dispatch.cfc untouched — events: explicit set(allowEnvironmentSwitchViaUrl=true) is indistinguishable from the default — documented override impossible #3031 owns the explicit-override/prod-guard trapdoor. The back-switch from testing works here precisely because the post-stop cold start carries no stale oldEnvironment/allowEnvironmentSwitchViaUrl state.
  • Guides reviewed (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:

Run pass fail error bundles
Baseline (unmodified develop) 4374 12 0 310
With fix + new spec 4386 12 0 311

The 12 failures are the known pre-existing wheels.tests.specs.internal.testClientSpec container 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=testpw302, location: / (params stripped) → /wheels/info?format=json still environment=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/info flips 200 JSON → 404 (public component disabled in testing).
  • testing → dev: 1 redirect, final=200/wheels/info 200 JSON, environment=development.
  • ?reload=true&password=testpw&foo=bar302, location: /?foo=bar (unchanged).
  • ?reload=development while in development → 200 directly (no restart — the documented no-op).
  • Wrong password → 200, no restart, env unchanged. ?reload=&password=testpw302, location: / (strip, no loop).

Fixes #3030

🤖 Generated with Claude Code

…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>

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.name is stable across requests despite this.name = createUUID() at public/Application.cfc:7, because config/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) does application[key] = keys[key] for every this-scope key (vendor/wheels/events/onapplicationstart.cfc:7-9) before the application.wheels.reloadPassword carryover read (lines 17-21), so seeding this.wheels.reloadPassword works 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-empty reloadPassword) 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 at vendor/wheels/events/onapplicationstart.cfc:222 is 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>

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:455 in $handleRestartAppRequest, :507 in $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 writes application.$wheels.allowEnvironmentSwitchViaUrl = false (lines 346–352), so on a stock production app the live flag is false, 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=true and 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.

@bpamiri
bpamiri merged commit a6df6be into develop Jun 11, 2026
6 checks passed
@bpamiri
bpamiri deleted the peter/issue-3030-reload-redirect-envswitch branch June 11, 2026 20:29
bpamiri added a commit that referenced this pull request Jun 12, 2026
…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>
bpamiri added a commit that referenced this pull request Jun 12, 2026
…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>
bpamiri added a commit that referenced this pull request Jun 12, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

app template: reload redirect strips the environment-switch params, making ?reload=<env> a silent no-op

1 participant