docs/internal/parallel-plugin-loading.md
Status: Design Plan
Scope: fresh-plugin-runtime, fresh-parser-js, fresh-core, fresh-editor
Fresh loads all plugins serially during startup. Each plugin goes through three phases sequentially: file I/O → TypeScript transpilation → QuickJS execution. With many plugins (20–80+), startup becomes noticeably slow. Additionally, there is no way to declare inter-plugin dependencies, and collisions on shared registries (commands, contexts, grammars) are silently accepted — last writer wins — which creates subtle, order-dependent bugs.
import syntax.registerCommand, register_grammar, set_context, etc.) must fail
loudly rather than silently overwrite.for each plugin_dir:
for each .ts/.js file (filesystem order):
1. read_to_string(path) # blocking I/O
2. read i18n JSON # blocking I/O
3. transpile_typescript(source) # CPU-bound (oxc)
or bundle_module(path) # CPU-bound (oxc + dependency resolution)
4. execute_js(js_code) # QuickJS execution (side effects)
Total time: N × (IO + transpile + exec).
Phase 1 — Parallel Preparation (thread pool / rayon):
discover all plugin files across all directories
for each plugin IN PARALLEL:
1. read_to_string(path)
2. read i18n JSON
3. parse imports to extract dependency metadata
4. transpile/bundle to JS
5. compute content hash, check transpile cache
collect Vec<PreparedPlugin>
Phase 2 — Serial Execution (plugin thread, single-threaded QuickJS):
topological sort by declared dependencies
for each plugin in topo order:
1. register i18n strings
2. execute_js(prepared.js_code)
3. register in plugins HashMap
Total time: max(IO + transpile) + N × exec.
/// Result of Phase 1 for a single plugin.
struct PreparedPlugin {
name: String,
path: PathBuf,
js_code: String, // transpiled/bundled JS, ready to execute
i18n: Option<HashMap<String, HashMap<String, String>>>,
dependencies: Vec<String>, // extracted from import statements
content_hash: u64, // for transpile cache invalidation
}
Plugins declare dependencies using a fresh:plugin/ import scheme:
// my-plugin.ts
import type { SomeType } from "fresh:plugin/utility-plugin";
This is parsed at transpile time to extract "utility-plugin" as a
dependency. The import statement itself is stripped before execution (just
like existing export stripping). The actual runtime inter-plugin API uses
explicit editor methods:
// utility-plugin.ts — exporting
editor.registerPluginExport("utility-plugin", {
formatDate: (d: Date) => d.toISOString(),
});
// my-plugin.ts — importing (at runtime)
const utils = editor.getPluginExport("utility-plugin");
if (!utils) throw new Error("utility-plugin not loaded");
import type + runtime API (not real ES imports)?import type is erased by the TypeScript transpiler, so it has zero runtime
cost and zero bundling complexity.fresh:plugin/ scheme is unambiguous — it cannot collide with local
file imports (which use ./ relative paths).fresh:plugin/* via a
paths entry in tsconfig.json or a generated .d.ts, giving full
autocompletion and type checking.getPluginExport makes the dependency explicit and allows for
graceful handling (null check = soft dependency).Extend fresh-parser-js to extract fresh:plugin/* imports:
/// Extract plugin dependency names from `import ... from "fresh:plugin/NAME"`.
pub fn extract_plugin_dependencies(source: &str) -> Vec<String> { ... }
This is a lightweight parse — regex or a single-pass line scan suffices since
the fresh:plugin/ scheme is syntactically unambiguous. For robustness, reuse
the existing oxc AST parse that already happens during transpilation.
Use Kahn's algorithm on the dependency graph. On cycle detection, report the full cycle path and refuse to load any plugin in the cycle:
Error: Plugin dependency cycle detected: A → B → C → A
Plugins A, B, C will not be loaded.
Plugins with no dependency relationships maintain alphabetical order for determinism (matching current behavior).
Currently, CommandRegistry::register (line 75 of command_registry.rs)
silently replaces existing commands:
// Current: silent overwrite
commands.retain(|c| c.name != command.name);
commands.push(command);
Similar silent-overwrite behavior exists for:
registered_actions HashMap in quickjs_backend.rs (line 804)set_context (keybinding contexts)register_grammar / register_language_config / register_lsp_serverThis makes plugin behavior dependent on load order — a bug source that becomes worse with parallel preparation (where the order of Phase 1 completion is non-deterministic).
First-writer-wins: the first plugin to register a name owns it. Subsequent attempts to register the same name fail with an exception thrown back to the calling plugin's JS context.
// command_registry.rs
pub fn try_register(&self, command: Command) -> Result<(), CommandCollisionError> {
let mut commands = self.plugin_commands.write().unwrap();
if commands.iter().any(|c| c.name == command.name) {
return Err(CommandCollisionError {
name: command.name,
existing_plugin: commands.iter()
.find(|c| c.name == command.name)
.map(|c| c.source.clone()),
});
}
commands.push(command);
Ok(())
}
The existing register method is kept for internal use (built-in commands that
legitimately override) but the plugin-facing path goes through try_register.
In quickjs_backend.rs, register_command currently returns bool. Change it
to throw a JS exception on collision:
pub fn register_command<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
name: String,
...
) -> rquickjs::Result<bool> {
// ... existing code ...
match self.command_sender.send(PluginCommand::TryRegisterCommand { command, response_tx }) {
Ok(()) => {
match response_rx.recv() {
Ok(Ok(())) => Ok(true),
Ok(Err(collision)) => Err(ctx.throw(
rquickjs::String::from_str(ctx.clone(),
&format!("Command '{}' already registered by {}",
collision.name, collision.existing_plugin)
)?.into_value()
)),
Err(_) => Ok(false),
}
}
Err(_) => Ok(false),
}
}
| Registry | Current behavior | New behavior |
|---|---|---|
registerCommand | Silent replace | Throw exception |
registered_actions (handler map) | Silent replace | Throw exception |
set_context | Silent replace | Allowed (contexts are meant to be toggled by anyone) |
register_grammar | Silent replace | Throw exception (first grammar for a scope wins) |
register_language_config | Silent replace | Throw exception |
register_lsp_server | Silent replace | Throw exception |
set_context is intentionally excluded — contexts are boolean flags that
multiple plugins may legitimately toggle (e.g., "panel_visible").
A plugin that calls unregisterCommand(name) first and then
registerCommand(name, ...) should succeed. The unregister clears the
ownership, allowing re-registration. This supports hot-reload workflows.
Store transpiled JS alongside a content hash in a cache directory:
~/.config/fresh/cache/plugins/
<plugin-name>.<content-hash>.js
On startup:
.ts source (e.g., xxhash64).<name>.<hash>.js exists in cache.Cache entries are cheap (a few KB each). Prune entries older than 30 days on startup.
The cache check happens inside Phase 1 (parallel), so cache hits make that phase nearly instant.
Files:
crates/fresh-editor/src/input/command_registry.rs — add try_registercrates/fresh-core/src/api.rs — add TryRegisterCommand variant with
response channelcrates/fresh-plugin-runtime/src/backend/quickjs_backend.rs — change
register_command to use synchronous collision check, throw on failurecrates/fresh-editor/src/services/plugins/bridge.rs — implement
try_register_commandTests:
command_registry.rs: register same command name twice →
second call returns Err(CommandCollisionError)quickjs_backend.rs (test_api_register_command area):
two registerCommand calls with same name → second throws JS exception"My Command" → editor starts, only
first plugin's handler is active, second plugin logs error via
editor.debug()Repeat analogous tests for register_grammar, register_language_config,
register_lsp_server, and the registered_actions handler map.
Files:
crates/fresh-parser-js/src/lib.rs — add extract_plugin_dependencies()
function that parses import ... from "fresh:plugin/NAME"crates/fresh-plugin-runtime/src/thread.rs — integrate dependency
extraction into plugin loading; add topological sort before executioncrates/fresh-core/src/api.rs — add dependencies: Vec<String> to
PluginConfig (informational, for UI/debugging)Tests:
extract_plugin_dependencies: various import styles
(import type, import { X }, import * as, multiline) correctly
extract dependency nameseditor_initialized → works correctlyFiles:
crates/fresh-plugin-runtime/src/thread.rs — split
load_plugins_from_dir_with_config_internal into Phase 1 (parallel
preparation) and Phase 2 (serial execution)crates/fresh-plugin-runtime/Cargo.toml — add rayon dependency (or
use tokio::task::spawn_blocking pool)crates/fresh-parser-js/src/lib.rs — ensure transpile_typescript and
bundle_module are Send (no Rc, no thread-local state) so they can
run on a thread poolApproach:
rayon::par_iter for Phase 1. The oxc allocator is per-invocation
and does not share state, so parallel transpilation is safe.bundle_module does recursive file reads — each invocation builds its own
visited set and module list, so it is safe to parallelize across
different entry points. However, two plugins that both import the same
local helper will each independently bundle it. This is fine — the
bundled output is per-plugin and the duplication is in-memory only.Vec<PreparedPlugin> already sorted by the topo sort
from Milestone 2.Tests:
js_code and dependenciesFiles:
crates/fresh-plugin-runtime/src/cache.rs (new) — transpile cache logic
(hash, read, write, prune)crates/fresh-plugin-runtime/src/thread.rs — integrate cache into Phase 1crates/fresh-core/src/config.rs — add plugin_cache_dir to
DirContextTests:
Files:
crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs — add
register_plugin_export and get_plugin_export methods to JsEditorApicrates/fresh-core/src/api.rs — add corresponding PluginCommand variantsTests:
| Aspect | Fresh (proposed) | VS Code | Neovim (lazy.nvim) | Zed |
|---|---|---|---|---|
| Parallelism | Parallel I/O+transpile, serial exec | Parallel (separate processes) | Serial (single Lua) | Parallel (WASM isolates) |
| Dependency syntax | import type from fresh:plugin/* | extensionDependencies in package.json | dependencies = {} in Lua config | None (isolated) |
| Collision handling | First-writer-wins, exception | Last-writer-wins (silent) | Varies by manager | N/A (isolated) |
| Lazy loading | Future work | Activation events | event, cmd, ft triggers | Language-based |
| Isolation | Shared context | Separate processes | Shared Lua state | WASM sandbox |
| Cache | Transpile cache | Built-in VSIX cache | Lockfile-based | WASM binary cache |
import syntax for deps and deferring lazy activation to a future milestone.| Risk | Mitigation |
|---|---|
| Parallel transpilation introduces non-determinism in execution order | Topo sort + alphabetical tiebreaker makes order fully deterministic regardless of Phase 1 completion order |
bundle_module does recursive file reads — parallel invocations could hit filesystem contention | Unlikely bottleneck; OS-level page cache handles this. Monitor with benchmarks. |
| First-writer-wins breaks plugins that intentionally override commands | Provide editor.overrideCommand() as an explicit, opt-in override API for this use case. Document the change in release notes. |
| Dependency on non-existent plugin silently accepted | Fail loudly at startup: "Plugin X depends on Y, but Y is not installed" |
| Plugin export API introduces new shared mutable state | Exports are keyed by plugin name, and only the owning plugin can write to its own namespace — no cross-plugin mutation |
| Cache grows unbounded | Prune old entries on startup (30-day TTL). Cache dir is in config, user can clear it. |