Skip to content

fix(dev-server): start a large Cloudflare app's dev server on remote runners - #265

Merged
mikn merged 1 commit into
mainfrom
mikn/dev-server-store-links
Oct 8, 2026
Merged

mikn merged 1 commit into
mainfrom
mikn/dev-server-store-links

Conversation

@mikn

@mikn mikn commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

On remote execution, ts_dev_server can't start a large application that uses @cloudflare/vite-plugin. Four defects stack:

  1. npm view entries lose their dependencies. Each dev-server npm view entry whose link was a plain store tree became a root symlink straight to that tree artifact. Remote execution copies such a tree in place, so the package no longer sits beside its dependency links in .pnpm/<store>/node_modules/. @cloudflare/vite-plugin then fails with ERR_MODULE_NOT_FOUND for miniflare.
  2. oj's fs.watch stalls the plugin host. oj_deno_runtime (ops/fs_events.rs) matches every filesystem event against every live watcher with same_file::is_same_file, which opens both files. It does this on notify's inotify thread, which drains every pending event before it registers the next watch. vite-plugin-bazel's BazelWatcher opens one watch per directory and per linked file under bazel-bin, 46,099 watches on a remote runner. The notify thread spins at 100% CPU, the plugin host's next synchronous fs.watch never returns, and configureServer never finishes. The server never opens its port.
  3. A realpath storm on every watcher event. For each event, vite-plugin-bazel's resolver asks whether the path is declared, and on a miss it realpaths every declared file to compare. With thousands of declared files that adds up to 9.07M realpathSync calls, and the JS thread stays at 100% CPU.
  4. oj's Start fallback renderer can't resolve extensionless imports. The pinned oj 0.2.5 hands a dependency's extensionless relative import (TypeScript source inside node_modules, e.g. index.ts → ./button) to plain Node, which answers 500 with ERR_MODULE_NOT_FOUND. Upstream fixed this in oj 0.2.9.

Defects 2 and 3 each hang startup alone: fixing only one still never serves a page.

Observed in production

Not observed in production. The ruleset builds development tooling and has no production runtime. All four were found starting a downstream application's dev server, plus a real-route test, on a remote runner.

Gain

On a remote runner, that application's dev server now starts and renders a real route, where before it hung until the test was killed. Its smoke and real-route tests together pass in 214.3 s on oj 0.2.16. With only the oj fix or only the resolver fix applied, it still hangs. Registering 20,000 watches on a patched oj build takes about 1.4 s; an unpatched build never initializes the host within 55 s. Local runs gain the same fixes; nothing measured them separately.

What changes

Area Change
ts/private/ts_dev_server.bzl Every npm view entry is a declared symlink (ctx.actions.declare_symlink) to its store's runfiles path. A store tree that is not itself a symlink joins the runfiles explicitly.
oj/oj-deno-fs-events-indexed.patch, oj_deno_runtime annotation Watchers are indexed by path, ancestor and (dev, inode), so each event reaches only its own watchers. Upstream: lovablelabs/oj#320. ops/fs_events.rs is unchanged through oj 0.2.16, so a version bump would not fix it.
vite/src/resolver.ts A declared file matches only a canonical query, which costs one realpath per lookup. Declared directories are still checked for every path.
oj 0.2.5 → 0.2.16 (oj/Cargo.toml, oj/Cargo.lock, MODULE.bazel, docs) Brings 0.2.9's extension probing for a dependency's relative imports in the Start fallback renderer. oj now uses its own oj_deno_snapshots and oj_deno_process forks in place of deno_snapshots/deno_process; V8 150.4.0, deno_core 0.412.0 and Cranelift 0.117.2 stay pinned. The lockfile moves only the oj crates.
oj patches oj-js-code-cache-bounded and oj-server-file-hmr are re-cut against 0.2.16 (oj_server split lib.rs into modules; 0.2.16 strips ?v=/?t= from cache keys but still keys Vite's timestamped config paths and never trims). The snapshot-source patch moves to oj_deno_snapshots and also reads its new CARGO_MANIFEST_DIR lookup at run time, because the process wrapper rejects the embedded absolute path. oj-deno-fs-events-indexed applies unchanged. The rest apply unchanged to the same crate versions.
tests/npm/direct_version_tests.bzl The npm analysis tests check each view entry as a declared alias with its store in the runfiles, as previous_dev_npm_test already did. Starlark exposes no target text for an unresolved symlink: content, argv and substitutions are None on its action under Bazel 9.2.0.
vite/tests/resolution_parity_test.mjs Pins that one non-declared watcher event costs a bounded number of realpaths (202 before this change).
changelog.d/ One fragment per fix.

Verification

On a remote runner:

  • bazel test --config=ci //vite/... //tests/dev_server/... //tests/dev_server_assets/... //tests/npm/... //tests/hmrsocket/... //oj/...: 138 of 138 pass in 360 s.
  • bazel test --config=ci //oj/... with oj built from scratch: 4 of 4 pass in 256 s.
  • The downstream application's dev-server tests at this commit pass: a smoke page plus a real route, 214.3 s.

Risks

  • Weaker analysis assertions. The five dev_inherited_types analysis tests no longer prove which store a view entry links to, only that the expected store is in its runfiles. The link target is still rlocation_path of the store the old code linked to.
  • More watches from oj 0.2.15 on. oj watches the directory of every file it serves from outside the app root, excluding node_modules. Sources reached through symlinks can add inotify watches on very large apps.
  • The re-cut patches have no unit tests of their own. The code cache and HMR patches are covered end to end only: the suite above and the real-route test. macOS runs in CI only.
  • Still one watch per linked file. BazelWatcher still opens a watch for every linked file; the fixes make that fast enough, not smaller.

🤖 Generated with Claude Code

@mikn
mikn force-pushed the mikn/dev-server-store-links branch from d1b8405 to 03971fe Compare October 7, 2026 21:21
@mikn mikn changed the title fix(dev-server): link npm view entries to their stores on remote runners fix(dev-server): start a large Cloudflare app's dev server on remote runners Oct 7, 2026
…runners

Four defects kept an application with thousands of declared files and the
Cloudflare Vite plugin from starting under ts_dev_server on remote runners:

- npm view entries were root symlinks to store trees, which remote execution
  copies in place, so a package (e.g. @cloudflare/vite-plugin) lost the
  dependency links beside its store directory (e.g. miniflare). Every entry is
  now a declared symlink to its store.
- oj's fs.watch matched every event against every watcher, opening both files,
  on the notify thread that also registers new watches. With vite-plugin-bazel
  watching each entry under bazel-bin (46k watches), that thread never caught
  up and the plugin host blocked in fs.watch before configureServer returned.
  oj/oj-deno-fs-events-indexed.patch indexes dispatch by path, ancestor and
  file identity (upstream: lovablelabs/oj#320).
- vite-plugin-bazel realpathed every declared file on each watcher event that
  missed its index (9M realpath calls). A declared file now only matches a
  canonical query.
- oj 0.2.5's Start fallback renderer handed a dependency's extensionless
  relative import to Node and answered 500 with ERR_MODULE_NOT_FOUND. oj moves
  to 0.2.16, which fixed it in 0.2.9.

oj 0.2.16 swaps deno_snapshots and deno_process for its oj_deno_snapshots and
oj_deno_process forks; V8, deno_core and Cranelift stay pinned. The snapshot
source patch moves to oj_deno_snapshots and also reads the fork's new
CARGO_MANIFEST_DIR lookup at run time, which the process wrapper otherwise
rejects as an embedded absolute path. The code cache and plugin-resolved file
HMR patches are re-cut against 0.2.16's sources, the fs.watch patch applies
unchanged, and the Start backport is dropped.

The npm analysis tests check each view entry as an alias with its store in the
runfiles: Starlark exposes no target text for an unresolved symlink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mikn
mikn force-pushed the mikn/dev-server-store-links branch from 03971fe to 1d3eb53 Compare October 7, 2026 22:13
@mikn
mikn merged commit 8ad03ba into main Oct 8, 2026
11 checks passed
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