Skip to content

emulate() never disposes the platform proxy, so every build leaks a workerd #16919

Description

@mbryzek

Describe the bug

Describe the bug

@sveltejs/adapter-cloudflare's emulate() calls wrangler's getPlatformProxy(), which
starts a miniflare workerd child process. The adapter never calls proxy.dispose() — the
string dispose does not appear anywhere in the package.

The adapter returns only { platform } from emulate(), and the proxy is captured in a closure, so nothing downstream can dispose it either — a userland wrapper cannot fix this without reimplementing emulate().

Why the leaked ports are not harmless

A wildcard bind does not reserve a port. The kernel's wildcard ephemeral allocator will hand out 0.0.0.0:N while the leaked workerd still holds 127.0.0.1:N; both binds coexist, and every connection goes to the most specific matching listener. So an unrelated program that binds 0.0.0.0:N and then dials 127.0.0.1:N reaches the leaked workerd instead of itself and blocks.

We ran into this but running scala tests: sbt.ForkTests binds its fork handshake with new ServerSocket(0) (wildcard) and its child dials 127.0.0.1 so on a machine where a SvelteKit app had been built, an unrelated Scala repo's test runner sat in accept() until the leaked workerd's idle timeout.

Why it is invisible

src/utils/fork.js carries the comment:

Runs a task in a subprocess so any dangling stuff gets killed upon completion.

It does not run a subprocess. It runs a node:worker_threads Worker, which shares the process so a child process the task spawned is not killed when the worker finishes, and is not killed when the worker is unref'd and torn down at process exit either. It is inherited by init.

Suggested fix

  1. Emulator gains an optional teardown. emulate() returns { platform } and the type has no disposal hook, so an adapter that acquires a resource has no way to release it.
    Adding dispose?: () => MaybePromise<void> to Emulator and awaiting it at the three call sites src/core/postbuild/prerender.js after prerendering, and the dev and preview servers on close would make this expressible.
  2. adapter-cloudflare implements it, holding the getPlatformProxy() result and calling proxy.dispose().

Reproduction

Any SvelteKit app on @sveltejs/adapter-cloudflare with export const prerender = true:

npm ci
lsof -c workerd -a -d cwd -Fn | grep -c "$PWD"    # 0
npm run build
lsof -c workerd -a -d cwd -Fn | grep -c "$PWD"    # 1

The leaked process:

$ ps -o pid=,ppid=,command= -p <pid>
25193  1  .../node_modules/@cloudflare/workerd-darwin-arm64/bin/workerd serve --binary ...
$ lsof -nP -p <pid> -a -iTCP -sTCP:LISTEN
workerd 25193 ... TCP 127.0.0.1:60893 (LISTEN)
workerd 25193 ... TCP 127.0.0.1:60894 (LISTEN)

Bisected against the other steps of the same CI script on a clean clone — vitest run, svelte-kit sync, svelte-check, playwright test --list all leave the count unchanged; vite build is the step that adds one.

Logs

System Info

- `@sveltejs/adapter-cloudflare` 7.2.4 and 7.2.9; also present in `8.0.0-next.6`, which still contains no `dispose`
- `@sveltejs/kit` 2.x
- `vite` 8
- macOS (arm64); the mechanism is not platform-specific

Severity

annoyance

Additional Information

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions