Skip to content

fix(runtime): path and url default to win32 semantics on Windows#6663

Merged
proggeramlug merged 2 commits into
mainfrom
fix/win-path-url-defaults
Jul 20, 2026
Merged

fix(runtime): path and url default to win32 semantics on Windows#6663
proggeramlug merged 2 commits into
mainfrom
fix/win-path-url-defaults

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Node-parity contract

On win32, Node aliases the default namespace to the win32 implementation: path === path.win32, so path.sep === '\\', path.delimiter === ';', and every path.* function uses Windows rules. url.fileURLToPath / url.pathToFileURL default their windows option to the platform (options?.windows ?? isWindows). Perry's runtime pinned all of these to POSIX regardless of host; this PR makes the DEFAULTS platform-correct while keeping the explicit path.posix.* / path.win32.* namespaces pinned.

What dispatches where

Perry has three routes into the path module, and all three now agree:

  1. Statically lowered calls (the common case): path.join(...) -> Expr::PathJoin -> the js_path_* extern symbols. These symbols are shared by default calls AND the HIR rewrite of path.posix.* member calls, so the platform switch lives INSIDE the default entry points: each js_path_X now does if cfg!(windows) { win32 } else { posix }, and the pre-dispatch POSIX bodies are preserved as a new pub(crate) js_path_posix_* family (crates/perry-runtime/src/path.rs).
  2. Runtime by-name dispatch (path[k].method(...), named-import value calls): nm_dispatch_path in crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs. The ("path", ...) arms now select by cfg!(windows); the ("path.posix", ...) arms were repointed from the base family (no longer posix-pinned) to js_path_posix_*.
  3. Property reads: path.sep/path.delimiter lower to js_path_sep_get/js_path_delimiter_get, now platform-aware. The runtime property-table getter for module "path" (object/native_module/constants.rs) was ALREADY cfg!(windows)-aware — the two read paths now agree instead of contradicting each other. path.posix.sep / path.win32.sep stay compile-time-folded pinned literals.

Keeping path.posix.* pinned required one HIR change (crates/perry-hir/src/lower/expr_call/nested_namespace.rs): the static rewrite of path.posix.X(...) produced the same Expr::PathX nodes as default calls — indistinguishable downstream. On Windows hosts only (cfg!(windows), so non-Windows lowering is byte-identical), dispatch_path_subnamespace now routes posix member calls to Expr::NativeMethodCall { module: "path.posix", .. }, i.e. the existing #1740 runtime-dispatch path whose arms call the pinned posix family. path.win32.* is untouched (already pinned to js_path_win32_*).

url changes

options_windows_flag (crates/perry-runtime/src/url/node_compat.rs) now defaults to cfg!(windows) when the options argument is missing, not an object, or its windows property is undefined/null — mirroring Node's options?.windows ?? isWindows. An explicit boolean still wins in both directions. This one choke point covers fileURLToPath, fileURLToPathBuffer, and pathToFileURL in both their statically-lowered and runtime-dispatched forms. The win32 arm of pathToFileURL also now resolves relative inputs against the cwd first (Node calls path.win32.resolve before building the URL) with trailing-separator preservation mirroring the posix arm. Internal machinery that is posix by construction (file_url_to_path_string_posix, module_base_to_path, fs PathLike decoding) is deliberately unchanged.

Why cfg!() runtime dispatch

cfg!(windows) inside perry-runtime is evaluated when the runtime is compiled for the target, so linked programs get target-correct semantics (unlike compile-time folding in the compiler, which would bake in the compiler host's platform). It also keeps both the win32 and posix code paths compiled on every host, so neither family can bit-rot behind a #[cfg].

Bug fixed along the way

win32_resolve_inner grafted the input's drive onto the cwd unconditionally — correct for driveless POSIX-host cwds, but on a Windows host path.resolve("C:foo") produced C:C:\Users\... garbage. Drive-relative inputs now resolve against the cwd when the drives match, fall back to the drive root when they differ, and keep the historical POSIX-host graft byte-for-byte.

Also: the pinned posix isAbsolute/resolve absoluteness check switched from std::path::Path::is_absolute() (host-dependent: applies win32 rules on Windows builds) to the lexical leading-/ check. Byte-identical on Unix; posix-correct on Windows.

File split

path.rs crossed the CI 2,000-line cap; the #[cfg(test)] modules moved verbatim to a child module crates/perry-runtime/src/path/tests.rs (path.rs is now ~1.9k lines).

Tests run (Windows 11 x64, MSVC host)

  • cargo check -p perry-runtime / -p perry-hir — clean (no warnings from changed code; pre-existing crate warnings unchanged).
  • cargo test -p perry-runtime --release -- path::tests url::node_compat::tests26 passed, 0 failed. Includes new suites:
    • path::tests::platform_default_tests#[cfg(windows)] asserts defaults are win32 (sep/delimiter/join/normalize/dirname/basename/extname/isAbsolute/resolve/relative/parse/toNamespacedPath); #[cfg(not(windows))] counterpart asserts posix defaults.
    • path::tests::posix_pinned_tests — platform-independent assertions that the js_path_posix_* family keeps / semantics on every host.
    • win32 drive-relative resolve tests cfg-split per host (POSIX graft vs Windows cwd-drive behavior).
    • url: windows_flag_defaults_to_platform, explicit_windows_option_wins_over_platform, cfg-split file_url_conversions_default_to_*.
  • rustfmt --edition 2021 --check on all five changed files — clean (CI's cargo fmt --all cannot run on this Windows host: os error 206, command line too long).
  • bash scripts/check_file_size.sh — my files pass (path.rs 1864 after moving its test modules to path/tests.rs); the script currently fails on perry-transform/src/inline/call_inliner.rs (2056) and perry-ui-windows/src/widgets/mod.rs (2118), both byte-identical on pristine origin/main — inherited, untouched by this PR.
  • python scripts/addr_class_inventory.py — no findings in any file this PR touches; the ratchet currently fails on bun_compat/mod.rs, object/global_this_webassembly.rs, object/collection_proto_thunks.rs, perry-stdlib ratelimit.rs/slugify.rs, all verbatim on pristine origin/main — likewise inherited.

Caveats:

  • The broader name-filter run cargo test -p perry-runtime --release path aborts with 0xc0000028 (STATUS_BAD_STACK) in two tests that merely match the filter by name — dyn_eval::tests::depd_fast_path_still_intact and native_arena::tests::disposed_native_uint8_views_throw_in_fallback_paths. Verified pre-existing by A/B: both crash identically on a pristine origin/main checkout with zero changes from this PR (at the time of the A/B that required the one-line ExitProcess declaration fix from fix(runtime): declare ExitProcess as never-returning on Windows (build break) #6609 to compile on Windows; this branch is now based on a main that already includes fix(runtime): declare ExitProcess as never-returning on Windows (build break) #6609, so nothing extra is carried here). They exercise the exception/throw machinery in release on MSVC and are unrelated to this change.
  • resolve_win32_for_namespace's handling of rooted-but-driveless paths is unchanged (toNamespacedPath("/tmp/x") -> \tmp\x, where Node on Windows gives \\?\C:\tmp\x) — pre-existing pinned win32-family behavior, out of scope.
  • CI parity/gap suites run on Linux, where every lowered form and runtime symbol in this PR behaves byte-identically to before (the HIR reroute is Windows-host-gated; the posix family is the same code the base family ran before).

No version bump/changelog per maintainer instruction.

Summary by CodeRabbit

  • Bug Fixes

    • Improved path handling across Windows and POSIX environments, including drive-relative paths, UNC paths, separators, resolution, parsing, and glob matching.
    • Ensured path.posix consistently uses POSIX behavior, regardless of the host platform.
    • Improved fileURLToPath and pathToFileURL platform detection, overrides, and Windows path resolution.
  • Tests

    • Added extensive coverage for platform-specific path behavior, URL conversions, glob patterns, edge cases, and invalid inputs.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds host-aware default path dispatch, pins path.posix operations to POSIX helpers, adjusts Windows drive-relative resolution, routes Windows namespace calls through runtime dispatch, expands path tests, and updates file URL conversion defaults and Win32 resolution.

Changes

Path semantics

Layer / File(s) Summary
Platform-dispatched path runtime
crates/perry-runtime/src/path.rs
Default path operations select Win32 or POSIX implementations by host, while explicit POSIX helpers remain pinned and drive-relative resolution is updated.
Path namespace dispatch
crates/perry-hir/src/lower/expr_call/nested_namespace.rs, crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs
Windows lowering routes supported path.posix methods through runtime dispatch, whose namespace arms call pinned POSIX helpers.
Path behavior validation
crates/perry-runtime/src/path/tests.rs
Tests cover POSIX parsing, glob handling, Windows paths, pinned POSIX behavior, platform defaults, and string-header validation.

URL path compatibility

Layer / File(s) Summary
Platform-aware file URL conversion
crates/perry-runtime/src/url/node_compat.rs
URL conversion defaults follow the host platform, explicit windows options override defaults, and Win32 paths are resolved before encoding.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main runtime change: Windows-default path and URL semantics.
Description check ✅ Passed The description is detailed and covers summary, changes, tests, and caveats, though it lacks the template's related issue and checklist sections.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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 fix/win-path-url-defaults

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.

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/perry-runtime/src/url/node_compat.rs (1)

491-521: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Resolve the Win32 path before checking for a UNC prefix.

Node's pathToFileURL calls path.win32.resolve() on the input before checking if it's a UNC path. By isolating the resolution step to the else branch, UNC inputs bypass path resolution entirely (meaning .. segments remain unnormalized). Additionally, inputs like //server/share miss the UNC branch because they don't begin with \\\\ until after resolution, causing them to fall into the else block and get incorrectly formatted as drive-letter paths (e.g., file:////server/share instead of file://server/share).

Move the Win32 resolution step outside and above the UNC check so that UNC paths are correctly resolved and identified.

🐛 Proposed fix
-        // Win32 (`#2975`). UNC paths (`\\host\share\...`) become
-        // `file://host/share/...`; everything else is a (drive-letter) path
-        // with `\` separators rewritten to `/`.
-        if let Some(unc) = path.strip_prefix("\\\\") {
-            // First segment after `\\` is the host; the remainder is the path.
-            let (host, rest) = match unc.find('\\') {
-                Some(idx) => (&unc[..idx], &unc[idx..]),
-                None => (unc, ""),
-            };
-            let rest_fwd = rest.replace('\\', "/");
-            format!("file://{}{}", host, encode_file_url_path(&rest_fwd))
-        } else {
-            // Node's win32 `pathToFileURL` resolves the input against the
-            // cwd first (`path.win32.resolve(filepath)`), then preserves a
-            // trailing separator — mirroring the posix arm below. Absolute
-            // inputs pass through resolution unchanged (modulo
-            // normalization).
-            let preserve_trailing_sep = path.ends_with('\\') || path.ends_with('/');
-            let mut resolved = crate::path::resolve_win32_str(&path);
-            if preserve_trailing_sep && !resolved.ends_with('\\') {
-                resolved.push('\\');
-            }
-            let fwd = resolved.replace('\\', "/");
-            let encoded = encode_file_url_path(&fwd);
-            if encoded.starts_with('/') {
-                format!("file://{}", encoded)
-            } else {
-                format!("file:///{}", encoded)
-            }
-        }
+        // Node's win32 `pathToFileURL` resolves the input against the
+        // cwd first (`path.win32.resolve(filepath)`), then preserves a
+        // trailing separator — mirroring the posix arm below. Absolute
+        // inputs pass through resolution unchanged (modulo
+        // normalization).
+        let preserve_trailing_sep = path.ends_with('\\') || path.ends_with('/');
+        let mut resolved = crate::path::resolve_win32_str(&path);
+        if preserve_trailing_sep && !resolved.ends_with('\\') {
+            resolved.push('\\');
+        }
+
+        // Win32 (`#2975`). UNC paths (`\\host\share\...`) become
+        // `file://host/share/...`; everything else is a (drive-letter) path
+        // with `\` separators rewritten to `/`.
+        if let Some(unc) = resolved.strip_prefix("\\\\") {
+            // First segment after `\\` is the host; the remainder is the path.
+            let (host, rest) = match unc.find('\\') {
+                Some(idx) => (&unc[..idx], &unc[idx..]),
+                None => (unc, ""),
+            };
+            let rest_fwd = rest.replace('\\', "/");
+            format!("file://{}{}", host, encode_file_url_path(&rest_fwd))
+        } else {
+            let fwd = resolved.replace('\\', "/");
+            let encoded = encode_file_url_path(&fwd);
+            if encoded.starts_with('/') {
+                format!("file://{}", encoded)
+            } else {
+                format!("file:///{}", encoded)
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/url/node_compat.rs` around lines 491 - 521, Update
the Win32 URL construction around the `href` branch to call
`crate::path::resolve_win32_str` before checking for the UNC prefix. Use the
resolved path for both UNC detection and formatting, so `..` segments are
normalized and slash-prefixed UNC inputs such as `//server/share` enter the
`file://host/share` path; preserve trailing-separator handling for non-UNC
paths.
crates/perry-runtime/src/path.rs (1)

553-566: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make js_path_posix_extname lexical Path::new(...).extension() is host-dependent here; path.posix.extname("a.b\\c") should return .b\\c, but Windows-target builds treat \ as a separator and can return "". Use the same lexical /-based split as the other pinned POSIX helpers, and add a backslash-containing test case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/path.rs` around lines 553 - 566, Update
js_path_posix_extname to determine the extension using POSIX lexical rules with
only “/” treated as a separator, rather than Path::new(...).extension(), so
backslashes remain part of the filename and “a.b\c” returns “.b\c”. Add a test
covering this backslash-containing case alongside the existing pinned POSIX
helper tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/perry-runtime/src/path.rs`:
- Around line 553-566: Update js_path_posix_extname to determine the extension
using POSIX lexical rules with only “/” treated as a separator, rather than
Path::new(...).extension(), so backslashes remain part of the filename and
“a.b\c” returns “.b\c”. Add a test covering this backslash-containing case
alongside the existing pinned POSIX helper tests.

In `@crates/perry-runtime/src/url/node_compat.rs`:
- Around line 491-521: Update the Win32 URL construction around the `href`
branch to call `crate::path::resolve_win32_str` before checking for the UNC
prefix. Use the resolved path for both UNC detection and formatting, so `..`
segments are normalized and slash-prefixed UNC inputs such as `//server/share`
enter the `file://host/share` path; preserve trailing-separator handling for
non-UNC paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3d70c3c1-ba73-40a0-a65d-a04f04cb1cf0

📥 Commits

Reviewing files that changed from the base of the PR and between f073bbf and 5d69001.

📒 Files selected for processing (5)
  • crates/perry-hir/src/lower/expr_call/nested_namespace.rs
  • crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs
  • crates/perry-runtime/src/path.rs
  • crates/perry-runtime/src/path/tests.rs
  • crates/perry-runtime/src/url/node_compat.rs

@proggeramlug
proggeramlug merged commit ec8030a into main Jul 20, 2026
27 checks passed
@proggeramlug
proggeramlug deleted the fix/win-path-url-defaults branch July 20, 2026 22:55
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