Skip to content

fix(cli): probe the dev port through the native Deno runtime - #3598

Closed
kojiwakayama wants to merge 1 commit into
mainfrom
fix/dx-20260811-r2-2
Closed

fix(cli): probe the dev port through the native Deno runtime#3598
kojiwakayama wants to merge 1 commit into
mainfrom
fix/dx-20260811-r2-2

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Symptom

Under a Deno-installed CLI, veryfront dev dies before it binds anything:

$ cd deno-app && deno task dev --port 3730
Task dev deno run -A npm:veryfront@0.1.1229 dev "--port" "3730"
Veryfront (v0.1.1229)

✗ [unknown-error] Unknown/unclassified error
  Detail: Cannot read properties of null (reading 'fd')
  Suggestion: Check logs for more details
$ curl -o /dev/null -w '%{http_code}' 127.0.0.1:3730   # -> 000, nothing listening

Reproduced against the published veryfront@0.1.1229 in a sandbox outside this
repo, on a project scaffolded by that same published CLI
(veryfront init deno-app --template ai-agent --runtime deno --yes). It is specific
to dev under Deno — build, serve, routes and doctor all succeed under the
same Deno-global install, and node ./node_modules/veryfront/bin/veryfront.js dev
on the identical project serves HTTP 200.

This blocks --runtime deno, which
/docs/code/getting-started/installation advertises as a supported install path.

Root cause

The finding said the .fd access was not in the framework's own esm/src but in a
dependency only reachable on the Deno dev path. It is @deno/shim-deno. Raw stack,
recovered by instrumenting the CLI error boundary in the published tarball:

TypeError: Cannot read properties of null (reading 'fd')
    at Object.listen (.../@deno/shim-deno/dist/deno/stable/functions/listen.js:44:64)
    at isPortAvailable (.../veryfront/esm/cli/commands/dev/port-fallback.js:38:26)
    at clearLocalCachesIfPortFree (.../veryfront/esm/cli/commands/dev/command.js:95:16)

cli/commands/dev/port-fallback.ts probed the port with a bare Deno listen call.
In the npm build dnt rewrites every bare Deno member access to dntShim.Deno
(@deno/shim-deno) — the published port-fallback.js line 38 really reads
dntShim.Deno.listen(...). That shim implements listen as

const server = createServer();
server.listen(port, hostname, resolve);
const listener = new Listener(server._handle.fd, ...);   // <- line 44

Under Node _handle is populated synchronously; under Deno's node:net compat it is
still null at that point, so the probe throws. The port scan runs unconditionally on
every dev start, so dev was dead on arrival for every Deno user.

Fix

Resolve the namespace through getDenoRuntime(), the existing platform helper that
reads the global with Reflect.get(globalThis, "Deno") — a form dnt does not rewrite,
which is exactly why src/platform/compat/http/native-response.ts already uses the
same trick for Deno.serve/Deno.upgradeWebSocket. One-line behaviour change; the
Node node:net fallback is untouched.

Test

cli/commands/dev/port-fallback.test.ts gains a guard that fails if the module reaches
the runtime through the binding dnt rewrites. It fails on the pre-fix source with
dnt would rewrite Deno.listen to the broken @deno/shim-deno namespace and passes
after. A plain unit test cannot catch this: in a Deno test run the bare Deno is the
native namespace, so the defect only exists in the built artifact.

Verified against the published repro

  1. Baseline, published 0.1.1229, fresh --runtime deno scaffold, the finding's own
    command deno task dev: Cannot read properties of null (reading 'fd'),
    curl000.
  2. deno task build:npm on this branch → npm pack → installed the tarball into a
    clean tree outside this repo → deno run -A .../veryfront/bin/veryfront.js dev
    in the same unmodified scaffold: ✓ Ready in 576ms, curl500, and
    grep -c "reading 'fd'" over the whole dev log → 0.

Not fixed here — a second, separate Deno-only defect

That 500 is not this bug. With the crash gone, the dev server binds and serves, but SSR
of the first page fails under Deno with

✗ Missing HTTP bundle after ensureHttpBundlesExist
    hash=0141fa6c…  expectedPath=.cache/veryfront-http-bundle/http-0141fa6c….mjs
✗ Render failed  error="Loading unprepared module: file://….cache/veryfront-http-bundle/http-0141fa6c….mjs,
    imported from: ….cache/veryfront-mdx-esm/…/veryfront/esm/_dnt.shims.js"

The bundle file does exist on disk (380 bytes, and so do its three transitive
http-*.mjs imports); Deno still rejects the dynamic import as an unprepared module, and
recoverHttpBundleByHash reports failure because there was nothing to recover. It is
deterministic across restarts and warm caches, and does not occur under Node on the same
project. That belongs to src/modules/react-loader/ssr-module-loader +
src/transforms/esm/http-cache.ts, not to the port probe, and is filed separately rather
than smuggled into this PR.

Summary by CodeRabbit

  • Bug Fixes

    • Improved development server port detection when running through npm-compatible builds.
    • Preserved existing port availability behavior in Node environments.
  • Documentation

    • Clarified port availability behavior and runtime compatibility considerations.
  • Tests

    • Added automated coverage to prevent regressions in npm build compatibility.

Under a Deno-installed CLI, `veryfront dev` died before binding with an
unclassified `Cannot read properties of null (reading 'fd')`, right after
"Using local filesystem (no proxy mode)". `build`, `serve`, `routes` and
`doctor` were fine; the npm-global install of the same version was fine.

The port scan added in #3562 probes with a bare `Deno` listen. In the npm
build dnt rewrites every bare `Deno` member access to `@deno/shim-deno`,
whose TCP listen reads `server._handle.fd` immediately after
`net.createServer().listen()` - and Deno's own `node:net` compat has not
populated `_handle` by then. Node never reached it, because the shim is
only in play when the published package runs under Deno.

Resolve the namespace through `getDenoRuntime()`, which reads the global
with `Reflect.get` and so survives the dnt rewrite, and guard the source
against the bare access coming back.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bb343ce2-666c-47a5-bf5c-0b76171958e2

📥 Commits

Reviewing files that changed from the base of the PR and between 2c2753d and 5bc177d.

📒 Files selected for processing (2)
  • cli/commands/dev/port-fallback.test.ts
  • cli/commands/dev/port-fallback.ts

📝 Walkthrough

Walkthrough

The port fallback now obtains the Deno runtime through getDenoRuntime() before probing ports. An npm build safety test prevents bare Deno.<member> references that dnt could rewrite incompatibly.

Changes

Deno runtime compatibility

Layer / File(s) Summary
Reflective runtime access and npm safety validation
cli/commands/dev/port-fallback.ts, cli/commands/dev/port-fallback.test.ts
isPortAvailable uses getDenoRuntime() for Deno port probing. Documentation records the compatibility constraint. The test rejects bare Deno.<member> references in the source.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: kwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main fix: using the native Deno runtime for development port probing.
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/dx-20260811-r2-2

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

@kojiwakayama

Copy link
Copy Markdown
Contributor Author

CI: the three red checks are pre-existing on main, not from this PR

coverage shard 4/8coverage gatetests (unit) are one failure and two aggregators of it.
The shard reports no failing tests; the process exits 1 after the run:

ok | 374 passed (3363 steps) | 0 failed (1m15s)
error: Promise resolution is still pending but the event loop has already resolved

Same signature on main at 3e76121ea, the commit this branch is based on
(run 31526576753, job 93896278630),
byte-for-byte: 374 passed (3363 steps) | 0 failed then the same
Promise resolution is still pending line, and the same three red checks. Shard 4 is the
shard that carries src/transforms/esm/http-cache.test.ts.

Evidence it is not this change:

  • Neither changed file is in shard 4's file list — cli/commands/dev/port-fallback.test.ts
    is not among the 200-odd files that shard runs.
  • The change adds no timer, no promise and no I/O; it swaps which object a synchronous
    listen() is called on.
  • Re-ran the failed jobs once; identical output. Not modified, per the "re-run rather than
    patch" rule for this failure class.
  • Locally the same text appeared once in the pre-push suite (3778 passed | 0 failed, then
    the same error) and the identical commit passed on the immediate retry.

Every other check is green, including ci (typecheck), ci (lint), ci (format),
tests (node), tests (bun), tests (integration), tests (binary e2e),
tests (npm install smoke), tests (rsc browser e2e), CodeQL and the other seven coverage
shards.

kojiwakayama added a commit that referenced this pull request Aug 11, 2026
Carries over the static invariant test from the duplicate PR #3598
alongside the behavioural one already here. They catch different things:

- the behavioural test poisons the ambient `Deno.listen` and asserts the
  probe still answers correctly, proving the fix works when the shim is
  in place;
- this one reads the source and asserts no bare `Deno.<member>` access
  survives anywhere in the file, catching a future reintroduction on
  paths the behavioural test never executes.

Verified red both ways before landing: against the pre-fix source both
tests fail, and with a bare `Deno.hostname()` planted in
`findAvailablePort`'s throw path the behavioural test passes while this
one fails naming `Deno.hostname`.

Comments are stripped before matching, since dnt rewrites code and not
prose, and `isPortAvailable`'s doc comment has to stay free to name
`Deno.listen` as the call the fix removed.
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Closing as a duplicate of #3599 — same bug, found independently, and this PR's analysis was right.

The diagnosis here stands on its own: dnt rewrites every bare Deno.<member> access in the npm build to dntShim.Deno.<member>, @deno/shim-deno's listen() reads server._handle.fd immediately after net.createServer().listen(), and Deno's node:net compat has not populated _handle by then — so the one branch written for Deno was the one branch Deno could not execute, and veryfront dev died under a Deno-installed CLI while the identical package ran fine under Node. The recovered stack through port-fallback.js:38, the verification against the published 0.1.1229 tarball, and the note that the remaining SSR Loading unprepared module 500 is a separate defect rather than part of this fix, all matched #3599 independently. Two agents converging on the same root cause from different repros is the strongest signal either PR had.

The invariant test from this PR has been carried over to #3599 — see eebdc22. It was not redundant with the test already there, and that is why it was worth porting:

  • fix(cli): probe dev ports without the dnt Deno shim #3599's behavioural test poisons the ambient Deno.listen with the exact TypeError and asserts the probe still reports a held port busy and a free port free. It proves the fix works when the namespace is shimmed.
  • this PR's static test reads port-fallback.ts and asserts no bare Deno.\w+ member access survives anywhere in the file. It catches a future reintroduction on paths the behavioural test never executes.

That complementarity was verified, not assumed. With a bare Deno.hostname() planted in findAvailablePort's throw path, the behavioural test passes and the ported static test fails with this PR's own message — dnt would rewrite Deno.hostname to the broken @deno/shim-deno namespace. Against the pre-fix source, both fail.

One adaptation was needed. #3599 fixes the bug by deleting the Deno branch entirely and probing with node:net on every runtime, rather than routing through getDenoRuntime() as this PR does. Both are correct — getDenoRuntime()'s Reflect.get(globalThis, "Deno") is genuinely dnt-proof, and isDeno is derived from it so it does not misfire under the shim — but the single-path version leaves no runtime-specific branch to keep correct, and makes the invariant structural rather than merely observed. Because that version's doc comment names Deno.listen as the call it stopped making, the ported test strips comments before matching: dnt rewrites code, not prose. String literals are deliberately left in, so the check fails toward rewording rather than toward silence.

The --runtime deno install path this unblocks is exactly the one /docs/code/getting-started/installation advertises, so thank you for chasing it to the shim rather than stopping at the error message.

Branch fix/dx-20260811-r2-2 is left in place, not deleted.

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