Skip to content

Replace node --version probe with no-fork PATH walk#1129

Merged
justin808 merged 1 commit into
jg/1123-binstub-sync-coveragefrom
jg/1123-node-binary-path-walk
May 24, 2026
Merged

Replace node --version probe with no-fork PATH walk#1129
justin808 merged 1 commit into
jg/1123-binstub-sync-coveragefrom
jg/1123-node-binary-path-walk

Conversation

@justin808

@justin808 justin808 commented May 21, 2026

Copy link
Copy Markdown
Member

Summary

Address the low-priority optimization from the PR #1104 follow-up review.

This is PR 3 of 3 for #1123 — the smallest of the three, isolated so it can land or be punted independently.

Change

shakapacker_node_binary in the helper binstubs previously verified Node was installed by running system("node", "--version") with both streams piped to /dev/null. That forks a subprocess on every binstub invocation — just to learn whether Node exists. Replaced with a direct walk of ENV["PATH"] that also probes PATHEXT-style extensions on Windows (so node.exe/node.cmd are still discoverable). One fork+exec saved per bin/shakapacker-config and bin/diff-bundler-config call.

The return value is unchanged (the literal string "node"), so the subsequent exec node_bin, script_path, *ARGV still goes through the OS's PATH resolution exactly as before.

Applied byte-identically across all four copies kept in sync by PR #1128 (binstub_sync_spec.rb + test/configExporter/createBinStub.test.js):

  • lib/install/bin/shakapacker-config
  • lib/install/bin/diff-bundler-config
  • spec/dummy/bin/shakapacker-config
  • the createBinStub template in package/configExporter/cli.ts

Stacked on PR #1128

This branch is stacked on top of jg/1123-binstub-sync-coverage (PR #1128). GitHub will auto-update the base to main once #1128 merges. If #1128 is closed instead, this PR can rebase onto main with a trivial change (only the comment header and NODE_ENV line differ, and only the comment-header lines need replaying).

Refs #1123.

Test plan

  • bundle exec rspec spec/shakapacker/binstub_sync_spec.rb — 2 examples pass (dummy↔install parity preserved)
  • bundle exec rspec spec/shakapacker/helper_binstubs_spec.rb — 8 examples pass, including the "node unavailable" path that uses PATH=/nonexistent
  • yarn jest test/configExporter/createBinStub.test.js — 2 examples pass (template still matches install templates byte-for-byte)
  • bundle exec rubocop — no offenses on changed Ruby files
  • yarn eslint — no errors on changed TS file
  • ruby -c on all three modified binstubs — syntax OK

🤖 Generated with Claude Code


Note

Medium Risk
Touches the helper binstubs used to launch Node-based tooling; any bug in the new PATH/PATHEXT scan could prevent commands from running, especially on Windows.

Overview
Replaces shakapacker_node_binary’s system("node", "--version") check with a no-fork scan of ENV["PATH"], including Windows PATHEXT extension probing.

Applies the same updated Node lookup logic across all four synced helper-binstub copies (lib/install/bin/*, spec/dummy/bin/*, and the createBinStub template in package/configExporter/cli.ts).

Reviewed by Cursor Bugbot for commit 1889de4. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: af585828-8be0-4b61-bcc7-9b023eabac14

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jg/1123-node-binary-path-walk

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

greptile-apps Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Replaces the system("node", "--version") subprocess probe in shakapacker_node_binary with a direct walk of ENV["PATH"], eliminating one fork+exec per binstub invocation. The change also adds PATHEXT-based extension probing for Windows compatibility.

  • The identical replacement is applied across all four binstub copies (lib/install/bin/shakapacker-config, lib/install/bin/diff-bundler-config, spec/dummy/bin/shakapacker-config, and the TypeScript template in package/configExporter/cli.ts), consistent with the sync contract documented at the top of each file.
  • The only behavioral difference is that the old probe ran node and verified it exits cleanly, while the new implementation checks only file existence and execute permissions — a broken-but-present node binary now surfaces an OS error at exec rather than the custom Shakapacker message, which is an acceptable tradeoff for the optimization.

Confidence Score: 5/5

Safe to merge — the PATH walk is a straightforward drop-in for the subprocess probe with no change to the return value or downstream exec call.

The substitution is mechanically simple: find a file named node (plus PATHEXT variants on Windows) in each PATH directory rather than spawning a subprocess to check. Edge cases are covered — nil PATH, empty PATH segments, and missing node — and all four binstub copies receive the identical change. The only observable difference is that a present-but-broken node binary surfaces at exec time instead of at the probe; this is a known, documented tradeoff.

No files require special attention.

Important Files Changed

Filename Overview
lib/install/bin/shakapacker-config Core binstub template: PATH walk replaces subprocess probe. Handles nil PATH via .to_s, empty dir components, Windows PATHEXT extensions, and falls through to exit 1 on miss. Logic is correct.
lib/install/bin/diff-bundler-config Byte-identical change to shakapacker-config's PATH walk. Synced correctly per the four-copy contract.
spec/dummy/bin/shakapacker-config Dummy app binstub updated identically to the install template; parity preserved for binstub_sync_spec.
package/configExporter/cli.ts Embedded Ruby template string updated with the same PATH walk; backtick and interpolation escaping matches surrounding TS template literal conventions.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[binstub invoked] --> B[shakapacker_node_binary]
    B --> C{Gem.win_platform?}
    C -- Yes --> D["extensions = ['', ...PATHEXT.split(';')]"]
    C -- No --> E["extensions = ['']"]
    D --> F[Walk ENV PATH directories]
    E --> F
    F --> G{dir empty?}
    G -- Yes --> H[skip]
    H --> F
    G -- No --> I[For each ext in extensions]
    I --> J["candidate = File.join(dir, 'node' + ext)"]
    J --> K{File.file? AND File.executable?}
    K -- Yes --> L[return 'node']
    K -- No --> I
    I -- exhausted --> F
    F -- exhausted --> M["warn: Could not find Node.js"]
    M --> N[exit 1]
    L --> O["exec node_bin, script_path, *ARGV"]
Loading

Reviews (1): Last reviewed commit: "Replace `node --version` probe with a no..." | Re-trigger Greptile


extensions.each do |ext|
candidate = File.join(dir, "#{node_bin}#{ext}")
return node_bin if File.file?(candidate) && File.executable?(candidate)

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.

Subtle behavioral change worth calling out: system("node", "--version") confirmed the binary was actually runnable — a wrong-arch binary (e.g., x86 node on an ARM machine), corrupted file, or empty-file-with-executable-bit would have failed the old probe and produced Shakapacker's friendly error. With the PATH walk, those same cases silently pass detection and the error surfaces later as a raw OS exec failure (Errno::ENOEXEC or similar) with no Shakapacker context.

Whether that tradeoff is acceptable depends on how likely those scenarios are in practice. For most dev environments it'll never matter, but it's worth a comment or doc note if this is intended.

# subprocess on every binstub invocation. On Windows, also probe
# PATHEXT-style extensions because executables there end in
# .exe/.cmd/.bat.
extensions = if Gem.win_platform?

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.

When ENV["PATHEXT"] is set but empty (e.g. PATHEXT=""), "".split(";") returns [], so extensions becomes [""] — only the bare node name is checked, and node.exe / node.cmd are skipped. Practically this won't happen on any real Windows install (empty PATHEXT breaks far more things), but if you want to be defensive the fallback condition could be:

Suggested change
extensions = if Gem.win_platform?
extensions = if Gem.win_platform?
pathext = ENV["PATHEXT"].to_s
pathext = ".COM;.EXE;.BAT;.CMD" if pathext.empty?
["", *pathext.split(";")]

Not a blocker — just a heads-up.

@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Review: Replace node --version probe with no-fork PATH walk

Overall: Clean, well-motivated optimization with good test coverage (including the PATH=/nonexistent edge case). The four-file sync is handled correctly. Two things worth considering before merge:

Behavioral nuance (minor)

system("node", "--version") verified that the node binary was actually runnable. The PATH walk only confirms a file with the right name exists and has the executable bit set. A corrupted binary, wrong-architecture binary, or a plain script missing a shebang would now pass the check and fail later at exec with a raw OS error (Errno::ENOEXEC), losing Shakapacker's friendly [Shakapacker] Could not find Node.js executable message. Inline comment on line 36 of shakapacker-config has more detail.

This is a reasonable tradeoff since exec failures in those cases are still clear enough in practice — just flagging it as intentional vs. oversight.

Windows PATHEXT with empty string (very minor)

If PATHEXT is set but empty (PATHEXT=""), the fallback to .COM;.EXE;.BAT;.CMD is skipped and only the bare node is probed. Won't affect real Windows installs (empty PATHEXT breaks far more than node discovery), but there is a code suggestion on line 25 if you want to harden it.

Other observations

  • The File.executable? check on the Windows branch is slightly redundant since PATHEXT-based extensions already narrow candidates to known-executable types, but it is harmless and actually correct on MRI Windows (Ruby checks extension vs. PATHEXT there too).
  • Comments are more verbose than the project's stated preference ("add one when the WHY is non-obvious") but the WHY here is legitimately non-obvious for someone reading the code cold, so justified.
  • The stacked-on-Tighten helper binstub parity and PATH lookup #1128 setup is clearly documented.

No blockers. The optimization is valid and the implementation is correct for the common case.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c11c5d94b8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

[""]
end

ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve executable lookup when PATH is unset

This PATH walk changes behavior versus the previous system("node", "--version") probe: if PATH is unset, ENV["PATH"].to_s becomes "", so the loop never checks anything and the binstub exits with “Could not find Node.js executable.” The previous implementation delegated lookup to process execution semantics, which can still resolve commands via the platform default search path (commonly including /usr/bin), so this introduces a false negative in sanitized environments that clear PATH. The same regression is duplicated in the other synced helper copies.

Useful? React with 👍 / 👎.

@justin808 justin808 force-pushed the jg/1123-binstub-sync-coverage branch from 27a0e53 to e1af730 Compare May 23, 2026 08:59
Address the low-priority polish item from the PR #1104 review.

`shakapacker_node_binary` previously verified Node was installed by
running `system("node", "--version")`, which forks a subprocess on
every binstub invocation just to confirm presence. Replace with a
direct ENV["PATH"] walk, falling back to PATHEXT-style extensions on
Windows. Cuts a fork+exec from every `bin/shakapacker-config` and
`bin/diff-bundler-config` call.

Applied identically across the three checked-in helper binstubs
(lib/install/bin/shakapacker-config, lib/install/bin/diff-bundler-config,
spec/dummy/bin/shakapacker-config) and the createBinStub template in
package/configExporter/cli.ts. binstub_sync_spec.rb +
test/configExporter/createBinStub.test.js (from PR #1128) keep the four
copies byte-aligned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@justin808 justin808 force-pushed the jg/1123-node-binary-path-walk branch from c11c5d9 to 1889de4 Compare May 23, 2026 09:00

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1889de42f1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

end

ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
next if dir.empty?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve current-directory PATH segments during lookup

Skipping empty PATH segments changes command-resolution semantics versus the previous system("node", "--version") probe: in POSIX, an empty segment in PATH (e.g. :/usr/bin or /usr/bin:) means “search the current directory.” With next if dir.empty?, the binstub now reports Node missing when node is only discoverable via that current-directory segment, even though the subsequent exec "node", ... would have found it before this change. This is a behavioral regression in environments that rely on empty PATH entries.

Useful? React with 👍 / 👎.

@claude

claude Bot commented May 23, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overall this is a clean, well-structured optimization. The change is applied byte-identically across all four synced copies, test coverage is solid (including the PATH=/nonexistent path that directly exercises the new logic), and the Windows PATHEXT probing is thoughtful.

Behavioral change worth acknowledging

The old system("node", "--version") verified the binary was actually runnable. The new filesystem walk only confirms a file exists and has the executable bit set. These usually agree, but edge cases exist:

  • A corrupted or architecture-incompatible binary (e.g. an x86_64 node on an ARM machine without Rosetta/binfmt) will pass the new check but then fail at exec time with a cryptic OS error instead of Shakapacker's helpful [Shakapacker] Could not find Node.js executable message.
  • A 0-byte file or a broken shim wrapper with the exec bit set would slip through similarly.

This is unlikely to affect real users, but it is a real regression in error-message quality worth calling out — particularly since the original system check was cheap enough that its main cost (the fork) only mattered in tight loops or on slow machines.

Open-source maintainability trade-off

The PR description labels this a low-priority optimization. The new code is ~20 lines replacing 1 line and introduces Windows-specific conditional logic. Per the project's own open-source guidelines ("prefer removing complexity over adding it"), the trade-off deserves explicit sign-off: the performance gain is real but narrow in impact, while the added complexity is permanent. This is not a blocker, just something for the reviewer to weigh consciously.

Windows File.executable? behaviour

On Windows MRI Ruby, File.executable? for .exe/.cmd files returns true based on file extension rather than ACLs. The combined check File.file?(candidate) && File.executable?(candidate) effectively reduces to File.file?(candidate) on Windows for those extensions — which is fine here but worth knowing.

Summary

The implementation is correct and well-tested. The main open questions are (1) whether the regression in error quality for broken binaries is acceptable, and (2) whether the complexity cost is worth it for a low-priority optimization. If both are consciously accepted, the code is ready to land.


extensions.each do |ext|
candidate = File.join(dir, "#{node_bin}#{ext}")
return node_bin if File.file?(candidate) && File.executable?(candidate)

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.

Subtle semantic change from the old system("node", "--version"): this verifies the binary exists and has the executable bit set, but not that it is actually runnable. A corrupted binary, an architecture-incompatible binary, or a broken shim will pass this check and then fail at exec time with an OS-level error rather than Shakapacker's helpful [Shakapacker] Could not find Node.js executable message. Worth documenting as an accepted trade-off.

# PATHEXT-style extensions because executables there end in
# .exe/.cmd/.bat.
extensions = if Gem.win_platform?
["", *(ENV["PATHEXT"] || ".COM;.EXE;.BAT;.CMD").split(";")]

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.

The "" first element intentionally checks node without any extension before trying the PATHEXT variants — this mirrors how the Windows CreateProcess API works (no-extension is tried first). The fallback .COM;.EXE;.BAT;.CMD is reasonable, though modern Windows typically also includes .PS1 in PATHEXT. Node.js ships as .exe so this covers the real-world case correctly.

@justin808 justin808 merged commit 4749299 into jg/1123-binstub-sync-coverage May 24, 2026
71 of 88 checks passed
@justin808 justin808 deleted the jg/1123-node-binary-path-walk branch May 24, 2026 01:13
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