Skip to content

Move plugin compilation off the server event loop - #3681

Open
ymichael wants to merge 3 commits into
mainfrom
bb/investigate-modal-sandbox-plugin-error-thr_7eghypnuzb
Open

ymichael wants to merge 3 commits into
mainfrom
bb/investigate-modal-sandbox-plugin-error-thr_7eghypnuzb

Conversation

@ymichael

@ymichael ymichael commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Human comments

What was wrong

The plugin runtime loaded every server entry, including compatible prebuilt marketplace and bundled artifacts, through JITI. JITI performs synchronous transformation and module loading, so loading a large bundle such as Modal Sandbox could block the server event loop long enough for BB Connect heartbeats to time out and report that the tunnel client was offline.

What changed

  • Compatible prebuilt dist/server.js artifacts now use native ESM import and avoid runtime transformation.
  • Source and path plugins compile asynchronously through esbuild into an atomic, content-addressed server cache, then the server natively imports the resulting artifact. Cache identity includes plugin source, BB and SDK versions, Node version, toolchain version, and build format.
  • Runtime compilation receives the manifest metadata already validated by the server, preserving runtime asset policy without weakening stricter release-build validation.
  • BB-owned runtime imports are resolved through native loader hooks while plugin-owned SDK subpaths compile from the plugin's installed SDK. Plugin factory execution remains in-process and retains its existing timeout.
  • JITI remains a shipped dependency and can be restored with the persisted legacyJitiPluginLoader BB experiment. The selection is read on each plugin load, so changing it affects the next install, update, enable, explicit reload, or server restart; it does not replace an already-running instance.
  • Added Settings UI, CLI coverage, configuration docs, and BB guide content for the experiment.
  • There are no server/host-daemon wire changes, so HOST_DAEMON_PROTOCOL_VERSION is unchanged.

How you verified

  • pnpm exec turbo run build --filter=bb-app — 49 tasks passed, including all bundled plugin builds and the packaged BB app.
  • pnpm exec turbo run typecheck --filter=@bb/domain --filter=@bb/db --filter=@bb/app --filter=@bb/cli --filter=@bb/server --filter=@bb/plugin-build --filter=@bb/templates --filter=bb-app — 12 tasks passed.
  • Native import, source compilation cache, cross-plugin dynamic import, prebuilt selection, JITI experiment, system experiment, and runtime logo compatibility tests passed.
  • Built-in plugin reconciliation and hot reload tests — 33 tests passed.
  • Settings UI — 6 tests passed; settings CLI — 11 tests passed; database experiment defaults — 1 test passed; server build behavior — 6 tests passed.
  • Lint completed with zero errors across all affected packages.
  • Smoke-tested bundled plugins including Modal Sandbox, Automations, ACP, and Documents, plus marketplace Git and npm install paths.

AGENT GENERATED

@ymichael
ymichael force-pushed the bb/investigate-modal-sandbox-plugin-error-thr_7eghypnuzb branch from 5510db1 to 3cba405 Compare September 14, 2026 23:44
@ymichael

Copy link
Copy Markdown
Collaborator Author

@bb-code-review

@bb-code-review

Copy link
Copy Markdown

🚨 SLOP COP 🚨 · bb-code-review

I’m the bb code review bot. I’m reviewing this PR now and will follow up with findings.

ymichael added a commit that referenced this pull request Sep 15, 2026
## Human comments

## What was wrong

The published-bridge regression built a real 682 KB host artifact and
then imported that generated artifact through Vitest's transform/module
pipeline. The package already externalized builtin generated host
artifacts for native Node loading, but the bridge fixture was omitted.
On the contended package shard, the necessary esbuild lifecycle plus
this avoidable second processing pass crossed Vitest's ordinary 5-second
test boundary. The failing CI run reported 5.500 seconds; the unchanged
test reproduced on the required Intel host at 5.142 seconds under 48
bounded CPU workers.

## What changed

Load the generated bridge host artifact through a cache-busted file URL
and include that fixture path in Vitest's existing native-artifact
externalization rule. Declare the fixture package as ESM so Node loads
the emitted ESM directly without reparsing or warning.

The real host build and all published bridge, bundled dependency, and
executed SemVer assertions remain intact. There are no server/daemon
wire changes, so `HOST_DAEMON_PROTOCOL_VERSION` does not change. PR
#3681 is the only open PR touching `packages/plugin-build`; it changes
the server builder and does not overlap these host-test files.

## How you verified

- CI evidence: https://github.com/get-bb/bb/actions/runs/35011632112,
`Tests (packages, ubuntu-latest, Node 22.x)`, exact 5.500-second timeout
at `build-plugin-host.test.ts:235`.
- Deterministic local red: unchanged test timed out at 5.142 seconds
under 48 bounded CPU workers on Intel `host_nwqfteeqz4`.
- Unloaded before/after: 1.281 seconds to 0.923 seconds.
- Same-load before/after: 5.142-second timeout to 2.731 seconds; three
further 48-worker runs passed at 2.755, 2.681, and 4.988 seconds.
- Complete focused file: 9/9 tests passed; target completed in 871 ms.
- `pnpm exec turbo run test typecheck build --filter=@bb/plugin-build
--output-logs=full`: 9/9 Turbo tasks passed; package tests passed 139
tests with 1 skipped, and typecheck/build passed.
- PR CI Node 22: the target passed in 2.720 seconds and all 139
`@bb/plugin-build` tests passed. The first run had unrelated Account
Pool toast-test and runner-download failures; rerunning only the failed
jobs passed, and CI run 35016480290 is green.
- `pnpm exec oxfmt --check
packages/plugin-build/src/build-plugin-host.test.ts
packages/plugin-build/vitest.config.ts` and `git diff --check` passed.
- Every load/test/build command had an external process-group deadline
with TERM/KILL cleanup. Post-run survivor scans were empty.

> AGENT GENERATED: by GPT-5.6-Sol.

@bb-code-review bb-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 SLOP COP 🚨 · bb-code-review

This PR replaces synchronous JITI loading with native imports and an asynchronous, content-addressed esbuild cache, while retaining JITI behind an experiment. That should prevent large compatible bundles from stalling the server heartbeat, and the UI/CLI fallback is wired through correctly. The default loader still has several compatibility and lifecycle defects that can break valid plugins or keep stale code running, though.

Findings

  1. P1 — Compatible prebuilt plugins fail when their package is CommonJS. packages/plugin-build/src/build-plugin-server.ts:127-133 always emits ESM to dist/server.js, and apps/server/src/services/plugins/plugin-runtime.ts:1673 now imports that file natively. The manifest schema accepts packages with "type": "commonjs", so Node treats the generated .js as CommonJS and fails on its first import statement. A focused plain Node 22.19 probe using the actual builder reproduced SyntaxError: Cannot use import statement outside a module. These packages loaded through JITI before this change; emit an unambiguously ESM artifact (for example .mjs or an ESM-scoped cache location), or reject the package type at the manifest boundary.

  2. P1 — Computed relative dynamic imports resolve from the cache directory instead of the plugin. Source bundles are relocated by apps/server/src/services/plugins/plugin-server-cache.ts:104, but esbuild leaves a non-static expression such as const target = "./helper.js"; await import(target) in the output. Node then looks for <cache>/helper.js and throws ERR_MODULE_NOT_FOUND; under JITI it resolved beside the source file. The new cross-plugin test covers only an absolute target. The cached artifact needs to preserve the source module's base for unresolved dynamic imports.

  3. P1 — The import.meta.url compatibility transform corrupts valid source and only preserves one use per file. packages/plugin-build/src/build-plugin-server.ts:154 performs a single raw text replacement before parsing. An occurrence in a string, template, regex, or comment can consume the replacement and can make the file invalid ("import.meta.url" becomes ""file:///…""); when a file has multiple actual meta-properties, later ones still point into the cache. plugins/monaco-editor/server.ts already contains two uses. The isolated browser run also installed a valid fixture that failed with Expected ";" but found "file", then loaded unchanged under the legacy JITI experiment. This needs a syntax-aware, per-module transform, including the equivalent import.meta.dirname/filename semantics.

  4. P1 — The cache key does not cover the full input graph esbuild compiles. apps/server/src/services/plugins/plugin-server-cache.ts:29-39 ignores every dist and node_modules path, records symlinks without hashing their targets, and hashes only the plugin root. Meanwhile esbuild can compile a bb.server entry under dist, bundle SDK subpaths from node_modules, follow symlinks, and bundle ../shared.js because the outside-root resolver at packages/plugin-build/src/build-plugin-server.ts:240 only sees absolute specifiers. In the live isolated app, changing only ../shared.js from outside-v1 to outside-v2 and successfully reloading still returned outside-v1. Key the cache from esbuild's complete resolved input graph, or externalize/reject every input outside the hashed tree.

  5. P1 — Reload generations accumulate permanently in memory, resolver state, and on disk. Each load at apps/server/src/services/plugins/plugin-runtime.ts:1671 creates a new query-qualified ESM identity, which Node cannot evict; a focused 500-generation probe retained about 60 MB of heap and 117 MB of RSS after forced GC. Each changed source digest also adds another mutable root that every later module resolution scans, and plugins/runtime/server has no retention or GC path. A normal long-running bb plugin dev session therefore grows memory and disk continuously and makes resolution progressively more expensive. Superseded roots/cache entries need bounded cleanup, and reloadable code needs a reclaimable worker/process/realm rather than permanent module-map identities.

The security pass found no separate authentication, authorization, injection, SSRF, unsafe-deserialization, or secret-handling regression; accepted server plugins were already full-trust code. The wider architecture scan's concrete concerns are included above rather than repeated.

Verification

The isolated source app, server, and daemon passed health/ownership checks. Through real browser input, the Legacy plugin loader experiment toggled and persisted, affected the next reload, and was restored; a disposable plugin also installed, disabled, re-enabled, reloaded, and changed from lifecycle-v1 to lifecycle-v2, with matching source CLI results. Focused tests passed (@bb/plugin-build 6/6 and selected server plugin suites 45/45), and the PR's GitHub checks are green. The verification inventory reported the pre-existing unrelated drift Unmapped CLI family: browser; nothing was changed to hide it. All fixtures, the isolated store, browser, and dev processes were cleaned up.

@ymichael

Copy link
Copy Markdown
Collaborator Author

Addressed all five review findings in acd2986:

  1. Release builds now place ESM in an explicit module scope, and existing compatible ESM prebuilds inside CommonJS packages are recompiled to a cached CJS runtime artifact.
  2. Computed relative dynamic imports are syntax-aware rewritten against their original source module URL.
  3. import.meta.url, import.meta.dirname, and import.meta.filename are transformed through esbuild parsing; strings, templates, regexes, and repeated uses remain intact.
  4. Runtime cache identity is derived from the compiled artifact, covering the actual bundled graph; static imports escaping the plugin tree are rejected explicitly.
  5. Source factories load through reclaimable CJS, are removed from both require.cache and parent.children, mutable resolver roots are owned per loaded plugin, and disk artifacts are capped at four generations.

Verification:

  • @bb/server plugin suite: 45 files / 501 tests passed
  • @bb/plugin-build: 11 files / 144 passed / 1 skipped
  • @bb/server and @bb/plugin-build typechecks passed
  • @bb/server, @bb/plugin-build, and @bb/cli production builds passed
  • Live worktree server: 30 plugins, zero error statuses; Modal reload succeeds in expected unconfigured state
  • Live source fixture verified computed relative import refresh and source-relative import.meta values across reload

@bb-code-review please re-review the latest commit.

@bb-code-review

Copy link
Copy Markdown

🚨 SLOP COP 🚨 · bb-code-review

I’m the bb code review bot. I’m reviewing this PR now and will follow up with findings.

@bb-code-review bb-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 SLOP COP 🚨 · bb-code-review

This PR changes how BB starts plugin backends: compatible packaged plugins are loaded directly by Node, while source-only or incompatible plugins are compiled asynchronously into a runtime cache instead of being transformed synchronously by JITI. The goal is to keep large plugin loads from freezing the server and dropping Connect heartbeats, with a documented experiment available to restore the old loader. The direction is good, but the new default path still changes valid plugin behavior and contains work that can block or repeat unnecessarily.

Findings

  1. P1 — The cached source path does not preserve the existing server-module contract. apps/server/src/services/plugins/plugin-server-cache.ts:118 forces every path plugin and source fallback through format: "cjs", even though plugin server entries and the normal release builder are ESM. That causes several independently reproduced failures:

    • a valid entry using top-level await fails during install with Top-level await is currently not supported with the "cjs" output format;
    • import.meta.resolve(...) becomes import_meta.resolve is not a function;
    • CommonJS-scoped source using __dirname or __filename sees the data-directory cache rather than the source directory, so adjacent assets fail with ENOENT;
    • a computed bare import such as import(packageName) resolves from the cache and cannot find a dependency installed in the plugin's own node_modules.

    The same fixtures load under the legacy JITI path. In the isolated app, the __dirname and import.meta.resolve failures were visible in Installed Plugins; enabling the legacy-loader experiment and reloading made both plugins healthy. This needs a module-loading design that preserves ESM and CommonJS source semantics, rather than adding more one-off replacements. The Plugin Guide should also be corrected: it currently says the cached artifact is native ESM while still saying path entries load TypeScript directly without a build step.

  2. P1 — Explicit reload can retain module-scoped state from the disposed ESM generation. apps/server/src/services/plugins/plugin-runtime.ts:269 returns a no-op when the digest is unchanged. The following native import therefore reuses the same query-qualified URL from Node's module cache. A focused service reproduction called reload twice: the plugin factory ran twice, but top-level module evaluation ran only once. Module-level singletons, counters, and resources can consequently leak from the old instance into the replacement even though reload previously used JITI with moduleCache: false.

  3. P1 — The computed-import compatibility rewrite can itself freeze the event loop. In packages/plugin-build/src/build-plugin-server.ts:115-119, every non-literal dynamic import reconstructs the entire transformed module with two slices and a concatenation. This callback runs in the server process, so the work is quadratic in module size and import count. A focused 304 KB / 5,000-import benchmark took 5.59 seconds and delayed a 2 ms timer by 5.41 seconds; the same build without this rewrite took 93 ms with 8 ms maximum lag. That recreates the class of heartbeat stall this PR is meant to remove. Build the rewritten output in one pass instead of repeatedly copying the full string.

  4. P2 — A warm cache hit still recompiles the whole plugin. apps/server/src/services/plugins/plugin-server-cache.ts:112-135 invokes buildPluginServer before calculating the digest and checking whether that artifact already exists. Unchanged source plugins are therefore read, transformed, bundled, source-mapped, and hashed on every reload and server start; only the final promotion is skipped. An instrumented repeat call returned the same cache path and unchanged mtime but invoked the toolchain twice. Because startup awaits plugin loads serially, warm-start cost grows with the sum of all source-plugin builds.

No separate authentication, authorization, injection, secret-handling, traversal, SSRF, unsafe-deserialization, or cache-poisoning regression was found. The wider architecture scan found no existing abstraction that removes the issues above.

The focused plugin-build, server loader/cache, plugin-service, builtin-plugin, and typecheck runs passed, and the PR's GitHub checks are green. The end-to-end worker was cancelled by a plugin restart before it could submit its structured result; I recovered its recorded browser/CLI evidence described above and verified cleanup myself. The isolated data directory, fixtures, browser, and dev processes are gone, all three ports are free, and the worktree is clean. The verification inventory also still reports the unrelated pre-existing drift Unmapped CLI family: browser.

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