Skip to content

fix(spawn): embed server source so spawnDaemon works in bundled consumers#38

Closed
schickling-assistant wants to merge 1 commit into
myobie:mainfrom
schickling-assistant:fix/spawn-daemon-bundle-safe
Closed

fix(spawn): embed server source so spawnDaemon works in bundled consumers#38
schickling-assistant wants to merge 1 commit into
myobie:mainfrom
schickling-assistant:fix/spawn-daemon-bundle-safe

Conversation

@schickling-assistant
Copy link
Copy Markdown
Contributor

Problem

When consumers compile this package into a single binary (e.g. bun build --compile), import.meta.url of dist/spawn.js resolves to a virtual path like bunfs:/.... The spawned node daemon child cannot read that path, so spawnDaemon fails with:

Cannot find module '@myobie/pty/server' from '/$bunfs/root/<bin>'

The existing setServerModulePath() escape hatch helps, but it puts the burden on every bundling consumer to ship a real on-disk copy of dist/server.js plus its transitive sibling deps and plumb a path through. That's fragile and easy to get wrong.

Approach

  • New scripts/embed-server-source.js runs after tsc in npm run build. It uses esbuild to bundle dist/server.js together with its in-package siblings into a single self-contained file (dist/server-source.txt). npm deps (node-pty, @xterm/*) stay external — bundled consumers must keep those resolvable from the spawned process, same as today.
  • src/spawn.ts now resolves the server module in three steps:
    1. setServerModulePath() override wins unconditionally (existing behaviour preserved).
    2. Fast path: sibling server.js next to spawn.js if it's a readable file.
    3. Fallback: materialise the embedded source into os.tmpdir()/myobie-pty-server-<sha256-prefix>/server.js and spawn that. One file per content hash, so concurrent daemons share a single tmpfile; not unlinked on spawn (the daemon needs it for its lifetime; OS reaps tmpdir).
  • No new public API. The escape hatch and the existing fast path are unchanged.
  • The src/ copy of server-source.txt is gitignored and only exists so the upstream test suite (which runs through tsx against src/) can exercise the fallback path.

Tests

tests/spawn-bundle-fallback.test.ts covers:

  • The embedded bundle is non-trivial, references npm deps as externals, and has no surviving relative imports.
  • End-to-end fallback: with no override and no sibling server.js, spawnDaemon materialises the bundle and the daemon comes up.
  • Tmpfile reuse: two spawns with identical source produce exactly one tmpdir entry.
  • The explicit setServerModulePath() override still wins.

npm run build && npx vitest run tests/spawn-bundle-fallback.test.ts tests/spawn-options.test.ts tests/integration.test.ts tests/supervisor.test.ts — 75 passed. Pre-existing failures in tests/shells.test.ts (zsh missing) and tests/screenshot.test.ts (vim env-dependent) are unrelated.

Context

Hit while bundling a downstream consumer via bun build --compile: schickling/dotfiles#836.

Independent of #37.

Posted on behalf of @schickling
field value
agent_name 🌸 cl2-glade
agent_session_id 246b956a-0258-4209-8265-143eb77329b1
agent_tool Claude Code
agent_tool_version 2.1.121
agent_runtime Claude Code 2.1.121
agent_model claude-opus-4-7
worktree pty/fix/spawn-daemon-bundle-safe
machine dev3
tooling_profile dotfiles@unknown-dirty

`spawnDaemon` previously did `spawn('node', [<__dirname>/server.js])`.
Under bundlers that virtualise the filesystem (`bun build --compile`,
esbuild single-file, etc.) `import.meta.url` resolves to a `bunfs:`-style
path the spawned `node` child can't read, and the daemon fails to start.
The first iteration of this PR addressed that with an embedded server-
source bundle materialised to `os.tmpdir()`. That worked for resolving
server.js itself but exposed a deeper failure mode: the materialised
file lives outside the consumer's `node_modules`, and ESM resolution
does not honour `NODE_PATH` — so the daemon can't find its own external
deps (`node-pty`, `@xterm/*`) either, and every consumer would need a
bespoke `node_modules`-symlink dance to recover.

Replace the embedded-source approach with CLI delegation. The resolution
strategy becomes:

  1. `setServerModulePath()` override — wins over everything (test
     harnesses, supervisors with custom paths).
  2. Sibling `__dirname/server.js` readable on disk — direct
     `node <server.js>` (existing fast path for ordinary npm installs).
  3. Bundled context — sibling unreadable. Shell out to `pty run -d
     --name <name> --cwd <cwd> [--isolate-env] [--tag k=v]... -- <cmd>
     <args>`. The CLI binary is always a real on-disk file with intact
     module resolution, so it sidesteps every bundling failure mode at
     once: spawning, server materialisation, daemon module resolution,
     native-binding loading.

Tradeoff: the CLI path doesn't surface every `SpawnDaemonOptions` field
(`rows`, `cols`, `displayCommand`, `displayName`, `ephemeral`,
`extraEnv`, `env`, `launcher`). For the dominant consumer pattern
(spawn a shell, attach a UI, resize after attach), only `cwd`, `name`,
`tags`, and `isolateEnv` are load-bearing at spawn time — all
supported. Add CLI flags upstream as concrete needs arise. Consumers
that need full fidelity in a bundled context can still call
`setServerModulePath()` with a real on-disk server.

Drops `scripts/embed-server-source.js`, the `dist/server-source.txt`
artifact, and the `esbuild` dev-dep that the embed pipeline required.
Tests cover all three resolution strategies (on-disk, CLI delegation,
explicit override) and the no-CLI-on-PATH error path.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@schickling-assistant schickling-assistant force-pushed the fix/spawn-daemon-bundle-safe branch from cd2f9ca to 86f7837 Compare May 6, 2026 14:26
@myobie
Copy link
Copy Markdown
Owner

myobie commented May 7, 2026

Landed via local rebase + fast-forward as commits cebe2d1 and adcec22 (the rebase resolved conflicts with the supervisor fix from b9a07bb, and the follow-up commit shortens session names in the new test to stay under macOS's 104-byte Unix socket path limit). Closing in favor of those direct merges. Thanks @schickling-assistant!

@myobie myobie closed this May 7, 2026
schickling-assistant added a commit to overengineeringstudio/effect-utils that referenced this pull request May 8, 2026
`@overeng/pty-effect/client`'s `spawnDaemon` previously reimplemented
the daemon spawn pipeline (server module resolution, child process
launch, socket wait, early-exit detection) to work around the lack of
a Bun-on-Node escape hatch in upstream `@myobie/pty.spawnDaemon`.

Upstream now ships a `launcher` option that covers exactly that case,
so the in-house path is obsolete. Replace it with a thin wrapper that
calls upstream `spawnDaemon` and sets `launcher` to `NODE_BIN ?? "node"`
when running under Bun. Public API and `PtyDaemonSpec` schema unchanged.

Consumers automatically inherit upstream improvements such as
bundle-safe spawn (myobie/pty#38) without any local workaround.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
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.

2 participants