Skip to content

[vitest-pool-workers] Fix module fallback failure on non-ASCII workspace paths (Windows) - #14713

Merged
petebacondarwin merged 14 commits into
cloudflare:mainfrom
allocsys:fix/14655-nonascii-fallback-redirect-header
Jul 20, 2026
Merged

[vitest-pool-workers] Fix module fallback failure on non-ASCII workspace paths (Windows)#14713
petebacondarwin merged 14 commits into
cloudflare:mainfrom
allocsys:fix/14655-nonascii-fallback-redirect-header

Conversation

@allocsys

@allocsys allocsys commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Fixes #14655

Problem

On Windows, when a project's workspace path contains non-ASCII characters (e.g. CJK characters like 開発), @cloudflare/vitest-pool-workers fails at startup with:

No such module "cloudflare:test-internal"

before any tests run.

Root cause

The module fallback service's redirect response (buildRedirectResponse() in module-fallback.ts) sets the target file path directly as the HTTP Location header value. HTTP headers are restricted to the Latin-1/ASCII byte range (enforced by undici's ByteString conversion), so any non-ASCII byte in the path throws:

TypeError: Cannot convert argument to a ByteString because the character at index 21
has a value of 38283 which is greater than 255.

This is the same anti-pattern previously fixed in #10774 for a different code path (WORKER_ENTRY_PATH_HEADER in vite-plugin-cloudflare), just a different call site.

Root cause confirmed against a real repro: forked gotisgate/workers-vitest-nonascii-path-repro and added a windows-latest CI workflow that recreates a kaihatsu-開発 path and runs the test suite with NODE_DEBUG=vitest-pool-workers, iterating until green.

Fix

Five coordinated changes were needed, all in packages/vitest-pool-workers:

  1. module-fallback.ts buildRedirectResponse() — percent-encode the file path with encodeURI() before setting it as the Location header.
  2. module-fallback.ts handleModuleFallbackRequest() — workerd passes the Location value through verbatim as the next request's specifier (it doesn't decode it), so decode it back with decodeURI() for filesystem resolution.
  3. module-fallback.ts buildModuleResponse() / load() — workerd tracks the in-flight request by the exact (still-encoded) specifier string and rejects the response if the module's name field doesn't match exactly, so a second rawTarget (undecoded) value is threaded through for the name field, while the decoded target is used for filesystem operations.
  4. module-fallback.ts — the encoded name becomes the referrer for every import inside that module, so referrer gets the same decodeURI() treatment as target.
  5. pool/index.ts connectToMiniflareSocket() + worker/index.ts — the MF-Vitest-Worker-Data header also embeds a raw (potentially non-ASCII) process.cwd() value; same class of bug, fixed with encodeURIComponent() / decodeURIComponent().

Verification

Ran the patched package against the real repro on Windows CI (workflow at ci/repro-windows.yml in the forked repro repo), confirming a clean run on the actual kaihatsu-開発 path:

Test Files  1 passed (1)
Tests  2 passed (2)

Latest green run: https://github.com/dumbCodesOnly/workers-vitest-nonascii-path-repro/actions/runs/29473133807

Two changesets are included, one per distinct header-encoding bug fixed (redirect Location header, and MF-Vitest-Worker-Data header).

  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: bug fix.

Open in Devin Review
allocsys added 8 commits July 16, 2026 06:11
… the module fallback redirect response

Fixes #14655. On Windows, a workspace path containing non-ASCII characters (e.g. CJK characters) caused `buildRedirectResponse()` to set a raw non-ASCII file path directly as an HTTP `Location` header value. HTTP headers are restricted to the Latin-1/ASCII byte range, so undici's `ByteString` conversion throws (`Cannot convert argument to a ByteString because the character at index N has a value of NNNNN which is greater than 255`), which surfaces to users as a misleading "No such module" runtime-start failure — since the very first module resolved via this path is the pool's own `cloudflare:test-internal` import.

This mirrors the fix already applied for the same class of bug in #10774 (a raw path set directly as an HTTP header value), just at a different call site. `encodeURI()` is used rather than `encodeURIComponent()` so that path separators and the Windows drive-letter colon (e.g. `/C:/a/b/c`) remain intact, matching how `ensureRootedPath()` already expects paths to look; only the actually-invalid header bytes (non-ASCII characters) get percent-encoded.
…d when building the redirect Location header

Half-fix follow-up: workerd carries the Location header value through verbatim as the next request's `specifier` param rather than URI-decoding it, so without this the encodeURI()'d path from the previous commit comes back containing literal percent-escapes instead of the real (non-ASCII) characters, and file resolution fails with "Not found". Verified end-to-end on Windows CI against the real repro (see PR description).
…rom the decoded one used for filesystem resolution

Second follow-up fix: workerd tracks the in-flight module request by the exact literal `specifier` string it sent (our encodeURI()'d Location value from the previous redirect), and rejects the response if the JSON module's `name` field doesn't match that literal string exactly ("returned module name does not match specifier"). Decoding `target` unconditionally (previous commit) broke this, since `name` is built from `target`. This threads a separate `rawTarget` (undecoded, but with the same Windows leading-slash handling) through to `load()`/`buildModuleResponse()` for the `name` field, while all internal filesystem resolution continues to use the decoded `target`. Verified end-to-end on Windows CI against the real repro.
… non-ASCII cwd

Third and final piece of the #14655 fix: this header carries the JSON-serialized project cwd, which is restricted to the Latin-1/ASCII byte range same as any HTTP header, but a non-ASCII workspace path breaks it the same way the Location header did. Verified end-to-end on Windows CI: the real repro now runs its test file, past this header, with no further ByteString errors.
…e we intentionally return in buildModuleResponse's identity field

Fifth piece of the #14655 fix. Since buildModuleResponse's `name` must stay raw/encoded to satisfy workerd's specifier matching (previous commit), that raw identity becomes the referrer for every import statement inside that module, propagating the encoded form forward to all subsequent fallback requests. Decoding referrer the same way as target fixes filesystem resolution for imports made from any module whose own name required encoding. Verified end-to-end: this was the actual cause of the next failure encountered on Windows CI (vitest/dist/worker.js failing to import @vitest/utils/source-map).
@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d47d872

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/vitest-pool-workers Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@workers-devprod
workers-devprod requested review from a team and petebacondarwin and removed request for a team July 16, 2026 05:41
@workers-devprod

workers-devprod commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • ✅ @cloudflare/wrangler
Show detailed file reviewers
devin-ai-integration[bot]

This comment was marked as resolved.

…al '%' in workspace paths

Devin Review flagged that decodeURI() was applied unconditionally to the
specifier and referrer before the try/catch that's supposed to handle
errors. A workspace path containing a literal '%' that was never
encodeURI()-produced (e.g. "50%off") throws URIError on decode, which
was uncaught here and crashed the whole handler instead of falling
through to the existing 404 response.

Add a safeDecodeURI() helper that catches URIError and falls back to
the raw value, so malformed/never-encoded paths degrade to a 404
instead of an unhandled rejection.
@allocsys

Copy link
Copy Markdown
Contributor Author

Thanks @devin-ai-integration[bot] — good catch. Fixed in f944dfc: handleModuleFallbackRequest now uses a safeDecodeURI() helper that catches URIError and falls back to the raw value, so a workspace path with a literal % that was never encodeURI()-produced (e.g. 50%off) no longer crashes the handler with an unhandled rejection — it now falls through to the existing 404 response instead.

Note this addresses the throw case specifically. The silent-corruption case (a literal %20 in a directory name being indistinguishable from our own encodeURI() output) would need the fallback service to track whether a given specifier/referrer actually came from one of its own redirects, rather than being decodable client-side — flagging as a possible follow-up rather than blocking this PR on it.

@pkg-pr-new

pkg-pr-new Bot commented Jul 17, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@14713

@cloudflare/config

npm i https://pkg.pr.new/@cloudflare/config@14713

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@14713

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@14713

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@14713

miniflare

npm i https://pkg.pr.new/miniflare@14713

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@14713

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@14713

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@14713

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@14713

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@14713

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@14713

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@14713

wrangler

npm i https://pkg.pr.new/wrangler@14713

commit: d47d872

devin-ai-integration[bot]

This comment was marked as resolved.

… encoding

The previous follow-up blindly `decodeURI()`'d every specifier/referrer and
caught the `URIError` that a literal `%` (e.g. `50%off`) would throw. That
only handled the *throwing* case: a real workspace path segment that happens
to look like valid percent-encoding (e.g. `build%20output`) was silently
decoded to the wrong path, and the "is this one of our own encoded values?"
decision was fundamentally a guess.

We own the only place these values are ever encoded
(`buildRedirectResponse()`), so mark our own output instead of guessing:

- `encodeRedirectLocation()` percent-encodes only paths that aren't
  header-safe and tags them with a rooted `/__mf_vitest_encoded__` sentinel.
- `decodeEncodedSpecifier()` decodes iff that sentinel is present; everything
  else (bare `cloudflare:*`/`node:*` specifiers, untouched original paths,
  paths with a literal `%`) passes through verbatim. This makes the change a
  strict superset of the pre-PR behaviour and removes any chance of
  mis-decoding a real `%`.

The sentinel is a leading, rooted segment on purpose: workerd derives a
relative import's specifier by joining it onto the referrer's directory
(`kj::Path::eval`), which drops the final path segment but keeps the leading
ones, so a trailing marker wouldn't survive for imports made from an encoded
module.

Literal `%` is now escaped before encoding so a path mixing non-ASCII and a
literal `%` round-trips losslessly (which `encodeURI()`/`decodeURI()` can't
do).

Adds unit tests driving the exported `handleModuleFallbackRequest` and the
encode/decode helpers directly, covering: literal-`%` pass-through (the
silent-corruption case the old code got wrong), non-ASCII and mixed
non-ASCII+`%` round-trips, astral characters, Windows drive-letter shape, a
non-ASCII redirect Location that no longer throws, an echoed sentinel
resolving the real file, and the 404 fall-through. `vitest.config.mts` gains
a `define` stub for the build-time builtin-modules global so these
source-importing unit tests can run.
@petebacondarwin
petebacondarwin force-pushed the fix/14655-nonascii-fallback-redirect-header branch from a2a1006 to b0a45bc Compare July 17, 2026 14:14
Comment thread .changeset/fix-nonascii-mf-vitest-worker-data-header.md Outdated
Co-authored-by: Pete Bacon Darwin <pete@bacondarwin.com>

@workers-devprod workers-devprod 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.

Codeowners reviews satisfied

@github-project-automation github-project-automation Bot moved this from Untriaged to Approved in workers-sdk Jul 20, 2026

@devin-ai-integration devin-ai-integration 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.

Devin Review found 2 new potential issues.

Open in Devin Review
Comment thread .changeset/fix-nonascii-vitest-pool-workers-redirect.md
Comment thread .changeset/fix-nonascii-mf-vitest-worker-data-header.md
@petebacondarwin
petebacondarwin merged commit de34449 into cloudflare:main Jul 20, 2026
61 checks passed
@github-project-automation github-project-automation Bot moved this from Approved to Done in workers-sdk Jul 20, 2026
@allocsys
allocsys deleted the fix/14655-nonascii-fallback-redirect-header branch July 20, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants