Skip to content

fix: preserve runtime env vars across Bundler.with_unbundled_env#2836

Merged
ihabadham merged 3 commits intomainfrom
ihabadham/fix/preserve-env-vars-unbundled-context
Mar 24, 2026
Merged

fix: preserve runtime env vars across Bundler.with_unbundled_env#2836
ihabadham merged 3 commits intomainfrom
ihabadham/fix/preserve-env-vars-unbundled-context

Conversation

@ihabadham
Copy link
Copy Markdown
Collaborator

@ihabadham ihabadham commented Mar 24, 2026

Summary

  • Fix ProcessManager#run_process_outside_bundle losing PORT and SHAKAPACKER_DEV_SERVER_PORT env vars when running foreman/overmind inside Bundler.with_unbundled_env

Problem

When foreman is system-installed (recommended by foreman docs — "Don't Bundle Foreman"), ProcessManager runs it inside Bundler.with_unbundled_env. This restores the pre-Bundler env snapshot, wiping any env vars set after Bundler.setup — including PORT and SHAKAPACKER_DEV_SERVER_PORT set by PortSelector.configure_ports.

Result: Auto-detected ports from PortSelector never reach foreman's child processes:

  • Foreman injects its default PORT=5000, overriding the selected port
  • Webpack dev server falls back to hardcoded 3035 from shakapacker.yml

This breaks the zero-config automatic port selection added in #2539.

Root Cause

Bundler.with_unbundled_env restores a snapshot of ENV captured before Bundler.setup ran. Any env var set at runtime after that point is nil inside the block. This is documented Bundler behavior and a known issue across the ecosystem.

Fix

Capture the port env vars before entering the unbundled block and pass them explicitly to system() as an env hash — using Ruby's Kernel#system(env, command, *args) signature.

This is the same pattern used by:

  • Rails' bundle_command in railties (8.0, 8.1)
  • Spring's process spawning with Process.spawn(env_hash, ...)
  • Puma's BundlePruner (captures GEM_HOME/BUNDLE_GEMFILE before with_unbundled_env)
  • This codebase's own PackGenerator (run_via_bundle_exec passes env hash to system() inside with_unbundled_context)

Test plan

Tested manually with a Rails app where foreman is system-installed (not in Gemfile):

  • Occupied ports 3000/3035 with dummy listeners
  • Ran bin/devPortSelector detected conflict, selected 3001/3036
  • Before fix: Rails bound to :5000 (foreman default), webpack crashed on :3035
  • After fix: Rails bound to :3001, webpack to :3036 — both correct

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) [email protected]

Summary by CodeRabbit

  • Bug Fixes

    • Development environment variables for the app server PORT and the dev-server port are now preserved when launching local development processes, so custom port settings persist across restarts.
  • Tests

    • Updated tests to accept an environment/options hash when asserting the launched process, and to verify that the relevant port variables are included only when set.

…rocessManager

When foreman/overmind is system-installed (not in Gemfile), ProcessManager
runs it inside Bundler.with_unbundled_env, which restores the pre-Bundler
env snapshot. This wipes PORT and SHAKAPACKER_DEV_SERVER_PORT set by
PortSelector.configure_ports, causing:
- Foreman injects its default PORT=5000 (overriding the selected port)
- Webpack dev server ignores SHAKAPACKER_DEV_SERVER_PORT (falls back to 3035)

Fix: capture these env vars before entering the unbundled block and pass
them explicitly to system() as an env hash — the same pattern used by
Rails' bundle_command (railties), Spring's process spawning, and this
codebase's own PackGenerator.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 24, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f58567a5-adce-4c7b-abfb-6da1a7ce4219

📥 Commits

Reviewing files that changed from the base of the PR and between 82210c1 and 0448e5e.

📒 Files selected for processing (3)
  • react_on_rails/lib/react_on_rails/dev/process_manager.rb
  • react_on_rails/sig/react_on_rails/dev/process_manager.rbs
  • react_on_rails/spec/react_on_rails/dev/process_manager_spec.rb
🚧 Files skipped from review as they are similar to previous changes (1)
  • react_on_rails/spec/react_on_rails/dev/process_manager_spec.rb

Walkthrough

Capture and pass selected runtime ENV keys (PORT, SHAKAPACKER_DEV_SERVER_PORT) when launching processes outside Bundler by adding an ENV_KEYS_TO_PRESERVE constant and a helper that builds an env hash, then invoking system(env_overrides, process, *args) so those vars survive Bundler's environment restoration.

Changes

Cohort / File(s) Summary
Process manager implementation
react_on_rails/lib/react_on_rails/dev/process_manager.rb
Add ENV_KEYS_TO_PRESERVE constant and preserve_runtime_env_vars helper; compute env_overrides and pass it as first arg to system when running processes outside Bundler so selected ENV keys persist.
Specs
react_on_rails/spec/react_on_rails/dev/process_manager_spec.rb
Update expectations to accept a leading Hash argument to Kernel#system and add examples asserting preserved ENV keys are included when set and omitted when unset.
RBS signature
react_on_rails/sig/react_on_rails/dev/process_manager.rbs
Expose ENV_KEYS_TO_PRESERVE: Array[String] and add class method signature for preserve_runtime_env_vars: () -> Hash[String, String] in the type interface.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I hop through envs with careful art,
I pluck the PORT and tuck it in my cart,
SHAKAPACKER's number snug and warm,
Bundler's gusts won't break their charm,
Off goes the process — steady, smart!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: preserve runtime env vars across Bundler.with_unbundled_env' directly and specifically describes the main change: capturing and passing runtime environment variables through Bundler's unbundled context to fix port selection.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ihabadham/fix/preserve-env-vars-unbundled-context

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 24, 2026

Greptile Summary

This PR fixes a well-understood Bundler behaviour where with_unbundled_env restores a pre-Bundler.setup snapshot of ENV, silently discarding runtime vars like PORT and SHAKAPACKER_DEV_SERVER_PORT that PortSelector sets after that snapshot is taken. The fix — capturing those vars before entering the block and forwarding them as an explicit env hash to system() — is the canonical solution used by Rails railties, Spring, and the codebase's own PackGenerator.

Key changes:

  • Adds ENV_KEYS_TO_PRESERVE = %w[PORT SHAKAPACKER_DEV_SERVER_PORT].freeze constant inside the singleton class.
  • Adds private helper preserve_runtime_env_vars that builds a hash of only the vars that are actually set.
  • run_process_outside_bundle now calls system(env_overrides, process, *args) instead of system(process, *args).

Issues found:

  • The two unit tests for run_process_outside_bundle were not updated for the new system() signature and will fail — system({}, "foreman", ...) does not match the existing with("foreman", ...) expectations.
  • The RBS type signature file is missing the new preserve_runtime_env_vars method and ENV_KEYS_TO_PRESERVE constant declarations.

Confidence Score: 4/5

  • Safe to merge after fixing the two broken unit tests; the production code change itself is correct and well-motivated.
  • The implementation is sound and follows established patterns in both Rails and this codebase. The only blocker is that the existing run_process_outside_bundle specs were not updated to match the new system(env_hash, ...) call signature and will fail in CI. Once those two test expectations (and ideally a new positive test for env preservation) are updated, the PR is ready to merge.
  • react_on_rails/spec/react_on_rails/dev/process_manager_spec.rb — two examples in .run_process_outside_bundle need updated system argument matchers.

Important Files Changed

Filename Overview
react_on_rails/lib/react_on_rails/dev/process_manager.rb Adds ENV_KEYS_TO_PRESERVE constant and preserve_runtime_env_vars helper; captures PORT/SHAKAPACKER_DEV_SERVER_PORT before with_unbundled_env and passes them as an explicit env hash to system(). Fix is correct and follows the established Rails/Spring/PackGenerator pattern.
react_on_rails/spec/react_on_rails/dev/process_manager_spec.rb The two existing run_process_outside_bundle examples were not updated: they assert system("foreman", ...) but the implementation now calls system({}, "foreman", ...), so both tests will fail. Also missing a positive test for the env-preservation behaviour.
react_on_rails/sig/react_on_rails/dev/process_manager.rbs New private method preserve_runtime_env_vars and constant ENV_KEYS_TO_PRESERVE are not reflected in the RBS type signature file.

Sequence Diagram

sequenceDiagram
    participant PS as PortSelector
    participant ENV as ENV (runtime)
    participant PM as ProcessManager
    participant BE as Bundler.with_unbundled_env
    participant FM as Foreman/Overmind

    PS->>ENV: ENV["PORT"] = "3001"
    PS->>ENV: ENV["SHAKAPACKER_DEV_SERVER_PORT"] = "3036"

    Note over PM: preserve_runtime_env_vars()
    PM->>ENV: read PORT, SHAKAPACKER_DEV_SERVER_PORT
    ENV-->>PM: { "PORT" => "3001", "SHAKAPACKER_DEV_SERVER_PORT" => "3036" }

    PM->>BE: with_unbundled_env { ... }
    Note over BE: Restores pre-Bundler ENV snapshot<br/>(PORT / SHAKAPACKER_DEV_SERVER_PORT now nil)

    BE->>FM: system(env_overrides, "foreman", *args)
    Note over FM: env_overrides re-injects PORT=3001<br/>SHAKAPACKER_DEV_SERVER_PORT=3036
    FM-->>PM: exit status
Loading

Comments Outside Diff (2)

  1. react_on_rails/spec/react_on_rails/dev/process_manager_spec.rb, line 173-186 (link)

    P1 Spec broken by new system() signature

    The run_process_outside_bundle tests were not updated to reflect the new system(env_overrides, process, *args) call signature.

    preserve_runtime_env_vars always returns a hash (empty {} when neither PORT nor SHAKAPACKER_DEV_SERVER_PORT is set, as is the case in the test environment). The new implementation therefore calls system({}, "foreman", "start", "-f", "Procfile.dev"), but the two existing expectations still assert system("foreman", "start", "-f", "Procfile.dev") — without the leading env hash. RSpec's with matcher requires exact argument matching, so both tests will fail.

  2. react_on_rails/sig/react_on_rails/dev/process_manager.rbs, line 19 (link)

    P2 RBS signature not updated for new method/constant

    The new private preserve_runtime_env_vars method and the ENV_KEYS_TO_PRESERVE constant introduced by this PR are not declared in the RBS signature file. If RBS type-checking is run in CI it will flag the missing declarations.

    Also add the constant alongside VERSION_CHECK_TIMEOUT:

    ENV_KEYS_TO_PRESERVE: Array[String]
    

Reviews (1): Last reviewed commit: "fix: preserve runtime env vars across Bu..." | Re-trigger Greptile

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Mar 24, 2026

Review

The fix correctly addresses the root cause — Bundler.with_unbundled_env restoring the pre-Bundler ENV snapshot — and the approach of capturing vars before the block and passing them via system(env, ...) is the right pattern.

A few issues to address:

Broken existing test

The existing test at spec line 176 will now fail. Before this change, system was called as system(process, *args). After, it is called as system(env_overrides, process, *args) where env_overrides is {} when the port vars are absent. An empty hash as the first arg is a different method signature, so the expectation .with('foreman', 'start', ...) won't match .with({}, 'foreman', 'start', ...).

Constant defined on the singleton class

ENV_KEYS_TO_PRESERVE is defined inside class << self, which attaches it to the singleton class, not ProcessManager itself. Referencing it as ProcessManager::ENV_KEYS_TO_PRESERVE from outside the class will raise NameError. It should be moved outside class << self, alongside VERSION_CHECK_TIMEOUT at the top of the class.

Missing test coverage for the new behaviour

There are no tests that verify env vars are actually forwarded when set. Worth adding tests for: when PORT is set it should appear in the env hash passed to system; when SHAKAPACKER_DEV_SERVER_PORT is set the same; and when neither is set the hash is empty.

Comment thread react_on_rails/lib/react_on_rails/dev/process_manager.rb Outdated
Comment thread react_on_rails/lib/react_on_rails/dev/process_manager.rb
Comment thread react_on_rails/lib/react_on_rails/dev/process_manager.rb
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
react_on_rails/lib/react_on_rails/dev/process_manager.rb (1)

180-184: Add a regression spec for env forwarding.

The helper looks good, but this path is easy to regress. Please add a spec that proves PORT and SHAKAPACKER_DEV_SERVER_PORT survive run_process_outside_bundle.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@react_on_rails/lib/react_on_rails/dev/process_manager.rb` around lines 180 -
184, Add a regression spec that sets ENV["PORT"] and
ENV["SHAKAPACKER_DEV_SERVER_PORT"], calls the dev process helper that runs
run_process_outside_bundle (so the code path that uses preserve_runtime_env_vars
is exercised), and then asserts those two keys still exist with the same values
after the call; locate the code paths by referencing preserve_runtime_env_vars
and run_process_outside_bundle, mock or stub external process execution as
needed so the spec only verifies environment forwarding, and ensure the test
fails if those env keys are removed or changed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@react_on_rails/lib/react_on_rails/dev/process_manager.rb`:
- Around line 180-184: Add a regression spec that sets ENV["PORT"] and
ENV["SHAKAPACKER_DEV_SERVER_PORT"], calls the dev process helper that runs
run_process_outside_bundle (so the code path that uses preserve_runtime_env_vars
is exercised), and then asserts those two keys still exist with the same values
after the call; locate the code paths by referencing preserve_runtime_env_vars
and run_process_outside_bundle, mock or stub external process execution as
needed so the spec only verifies environment forwarding, and ensure the test
fails if those env keys are removed or changed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b4c8db46-c3a3-4528-b767-8ff5a1d57032

📥 Commits

Reviewing files that changed from the base of the PR and between d7e7a91 and 3782aa9.

📒 Files selected for processing (1)
  • react_on_rails/lib/react_on_rails/dev/process_manager.rb

Comment thread react_on_rails/lib/react_on_rails/dev/process_manager.rb Outdated
Comment thread react_on_rails/spec/react_on_rails/dev/process_manager_spec.rb
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Mar 24, 2026

The core fix is correct and follows the established pattern (Rails railties, PackGenerator). Three issues need addressing before merge: (1) ENV_KEYS_TO_PRESERVE is inside class << self, making ProcessManager::ENV_KEYS_TO_PRESERVE raise NameError — move it outside alongside VERSION_CHECK_TIMEOUT. (2) The spec update uses a_kind_of(Hash) which does not verify the hash contents — add a test that sets ENV PORT and asserts it appears in the system() call. (3) react_on_rails/sig/react_on_rails/dev/process_manager.rbs is missing ENV_KEYS_TO_PRESERVE and preserve_runtime_env_vars declarations — steep check will flag them. See inline comments for details.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
react_on_rails/spec/react_on_rails/dev/process_manager_spec.rb (1)

173-180: Consider adding a test that verifies specific env vars are preserved.

The a_kind_of(Hash) matcher verifies the structural change but doesn't assert that the correct environment variables (PORT, SHAKAPACKER_DEV_SERVER_PORT) are actually captured and passed. Since no env vars are set up in this test, preserve_runtime_env_vars returns an empty hash.

Consider adding a test case that sets these env vars and verifies they appear in the hash:

♻️ Suggested additional test
it "preserves runtime env vars when Bundler is available" do
  allow(ENV).to receive(:[]).and_call_original
  allow(ENV).to receive(:[]).with("PORT").and_return("3001")
  allow(ENV).to receive(:[]).with("SHAKAPACKER_DEV_SERVER_PORT").and_return("3036")
  
  expect(described_class).to receive(:with_unbundled_context).and_yield
  expect_any_instance_of(Kernel).to receive(:system)
    .with({ "PORT" => "3001", "SHAKAPACKER_DEV_SERVER_PORT" => "3036" }, "foreman", "start", "-f", "Procfile.dev")

  described_class.send(:run_process_outside_bundle, "foreman", ["start", "-f", "Procfile.dev"])
end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@react_on_rails/spec/react_on_rails/dev/process_manager_spec.rb` around lines
173 - 180, Add a new spec that sets ENV["PORT"] and
ENV["SHAKAPACKER_DEV_SERVER_PORT"] (stub ENV.[] to return values) and then
asserts run_process_outside_bundle calls with_unbundled_context and calls
Kernel.system with a hash containing those exact keys/values; specifically mock
described_class.with_unbundled_context to yield and
expect_any_instance_of(Kernel).to receive(:system).with({ "PORT" => "3001",
"SHAKAPACKER_DEV_SERVER_PORT" => "3036" }, "foreman", "start", "-f",
"Procfile.dev") so preserve_runtime_env_vars behavior is verified when invoking
described_class.send(:run_process_outside_bundle, "foreman", ["start", "-f",
"Procfile.dev"]).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@react_on_rails/spec/react_on_rails/dev/process_manager_spec.rb`:
- Around line 173-180: Add a new spec that sets ENV["PORT"] and
ENV["SHAKAPACKER_DEV_SERVER_PORT"] (stub ENV.[] to return values) and then
asserts run_process_outside_bundle calls with_unbundled_context and calls
Kernel.system with a hash containing those exact keys/values; specifically mock
described_class.with_unbundled_context to yield and
expect_any_instance_of(Kernel).to receive(:system).with({ "PORT" => "3001",
"SHAKAPACKER_DEV_SERVER_PORT" => "3036" }, "foreman", "start", "-f",
"Procfile.dev") so preserve_runtime_env_vars behavior is verified when invoking
described_class.send(:run_process_outside_bundle, "foreman", ["start", "-f",
"Procfile.dev"]).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 822e878d-d317-4ca9-b692-3850ccd967dd

📥 Commits

Reviewing files that changed from the base of the PR and between 3782aa9 and 82210c1.

📒 Files selected for processing (1)
  • react_on_rails/spec/react_on_rails/dev/process_manager_spec.rb

…sts, update RBS

Move ENV_KEYS_TO_PRESERVE outside class << self to match
VERSION_CHECK_TIMEOUT placement and allow external access.

Add regression specs that verify PORT and SHAKAPACKER_DEV_SERVER_PORT
are forwarded in the env hash passed to system(), and that unset vars
are omitted.

Update RBS signature with the new constant and method declarations.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Comment thread react_on_rails/lib/react_on_rails/dev/process_manager.rb
Comment thread react_on_rails/spec/react_on_rails/dev/process_manager_spec.rb
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Mar 24, 2026

Review

Overall: solid fix, minimal and well-reasoned. The root cause analysis is accurate — Bundler.with_unbundled_env restores the pre-Bundler env snapshot, and the fix correctly captures the affected vars before entering the block and passes them explicitly to system() via Ruby's Kernel#system(env, cmd, *args) signature.

What's right

  • Capturing env_overrides before with_unbundled_context (not inside it) ensures the values read from ENV are the current runtime values, not the restored pre-Bundler snapshot. The ordering is correct.
  • The else branch (Bundler not defined) correctly skips env preservation — with_unbundled_env was never called in that path, so env vars are already in scope.
  • process_available_in_system? does not need the same treatment since it only does a version check — env vars aren't relevant there.
  • RBS signatures are updated in sync with the implementation change.
  • The pattern matches Rails railties, Spring, and this codebase's own PackGenerator — consistent precedent.

Minor concerns

  1. Hardcoded ENV_KEYS_TO_PRESERVE list (inline comment on line 17): The list is correct for today, but if PortSelector or Shakapacker adds new runtime env vars in the future they'll silently fail. A note in the constant comment about this being the extension point would help future maintainers.

  2. Missing "both nil" test case (inline comment on spec line 204): The three test cases cover "both set", "one missing", and the generic "is a Hash" shape, but not "both nil → empty hash {}". system({}, cmd, *args) is valid Ruby but verifying this edge case explicitly would complete the contract.

Neither of these is a blocker. The fix is correct and the tests cover the meaningful cases.

@ihabadham ihabadham merged commit 8d1847b into main Mar 24, 2026
51 checks passed
@ihabadham ihabadham deleted the ihabadham/fix/preserve-env-vars-unbundled-context branch March 24, 2026 19:25
justin808 added a commit that referenced this pull request Mar 27, 2026
…olve-2835

* origin/main: (21 commits)
  docs: fix profiling node renderer command (#2863)
  generators: point Pro install fallback to upgrade guide (#2868)
  Add RSC Flight payload optimization guide (Article 7) (#2827)
  Migrate from deprecated Async::Variable to Async::Promise (#2832)
  docs: turn pro quick start into a gateway (#2862)
  Fix upload-assets endpoint duplicating bundles across directories (#2768)
  docs: fix stale docs links and help URLs (#2850)
  docs: replace dead pro.reactonrails.com links (#2851)
  docs: refresh generator and helper URLs (#2852)
  Add standalone RSC upgrade guide for existing Pro apps (#2831)
  Raise docs version floor to 16.4.0 in install/demo guidance (#2610)
  Fix release script: require changelog, fix RC version computation (#2848)
  Bump version to 16.5.0
  Bump version to 16.5.0.rc.0
  Update CHANGELOG.md for 16.5.0.rc.0 (#2847)
  Docs: add memory leak prevention guide for Node Renderer SSR (#2845)
  Docs: fix RSC migration gaps found during real-world migration (#2842)
  Add common mistakes sections to RSC migration guides (#2826)
  fix: preserve runtime env vars across Bundler.with_unbundled_env (#2836)
  Skip Pro CI workflows for Dependabot PRs (#2825)
  ...

# Conflicts:
#	CHANGELOG.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant