How JS and Python Process Extensions Are Executed Differently in Modly's ProcessRunner
Modly's ProcessRunner uses persistent Node worker threads for JavaScript extensions and spawns fresh Python subprocesses for each Python extension run, with distinct message protocols and lifecycle management for each language.
In the lightningpixel/modly codebase, the ProcessRunner architecture provides a unified interface for executing extension code while handling the fundamental differences between JavaScript and Python runtimes. Understanding these execution differences is critical for extension developers optimizing performance or debugging cross-language integrations.
Architecture Overview: IProcessRunner Interface
Both JavaScript and Python extensions implement a common IProcessRunner interface, registered in a singleton Map<string, IProcessRunner> registry. Callers retrieve runners via getProcessRunner() for JavaScript or getPythonProcessRunner() for Python, ensuring each extension receives a dedicated runner instance.
The key distinction lies in the concrete implementations:
- JavaScript:
ProcessRunnerclass—wraps a persistent Node worker thread - Python:
PythonProcessRunnerclass—spawns a fresh subprocess per execution
JavaScript Extension Execution: Persistent Worker Threads
Worker Creation and Code Loading
JavaScript extensions run inside Node worker threads defined by the WORKER_CODE string in electron/main/process-runner.ts (lines 8-45). The worker establishes a custom require function pointing to the extension's node_modules:
// From process-runner.ts lines 8-45 - WORKER_CODE template
const WORKER_CODE = `
const { parentPort, workerData } = require('worker_threads');
const Module = require('module');
const path = require('path');
// Create custom require for extension's node_modules
const require_ext = Module.createRequire(
path.join(workerData.extDir, 'package.json')
);
// Load and execute the extension's entry file
const processor = require_ext(path.join(workerData.extDir, workerData.entry));
// ...
`;
The ensureReady() method (lines 89-118) creates this worker on first call and reuses it for subsequent run() invocations:
// From process-runner.ts lines 89-118
private async ensureReady(): Promise<void> {
if (this.worker) return;
this.worker = new Worker(WORKER_CODE, {
workerData: {
extDir: this.extDir,
entry: this.entry,
workspaceDir: this.workspaceDir,
tmpDir: this.tmpDir
},
eval: true
});
// Wait for 'ready' message from worker
await new Promise<void>((resolve, reject) => {
this.worker!.once('message', (msg) => {
if (msg.type === 'ready') resolve();
else reject(new Error(`Unexpected init message: ${msg.type}`));
});
});
}
Message Protocol
JavaScript extensions communicate via worker_threads messaging:
| Message Type | Direction | Purpose |
|---|---|---|
ready |
Worker → Parent | Signals initialization complete |
log |
Worker → Parent | Extension log output |
progress |
Worker → Parent | Progress updates (pct, label) |
done |
Worker → Parent | Execution completed with result |
error |
Worker → Parent | Execution failed with error |
The run() method (lines 120-147) handles these messages, resolving or rejecting the returned Promise based on done or error responses.
Python Extension Execution: Fresh Subprocess Per Run
Process Spawning and Input Protocol
Python extensions cannot execute in Node threads, so PythonProcessRunner launches a separate Python interpreter for each run() call. As documented in lines 56-60 of process-runner.ts:
// Python protocol: single JSON line on stdin, line-delimited JSON on stdout
// Messages: {type: 'progress'|'log'|'done'|'error', ...}
// Non-JSON lines are treated as log output
The run() implementation (lines 81-95) spawns the process and writes input data:
// From process-runner.ts lines 81-95
async run(
processInput: ProcessInput,
params: Record<string, unknown>,
onProgress: (pct: number, label: string) => void,
onLog: (message: string) => void
): Promise<unknown> {
const pythonProcess = spawn(this.pythonExe, [
path.join(this.extDir, this.entry)
], {
cwd: this.workspaceDir,
env: { ...process.env, MODLY_TMP_DIR: this.tmpDir }
});
// Send input as single JSON line on stdin
pythonProcess.stdin.write(JSON.stringify({ input: processInput, params }) + '\n');
pythonProcess.stdin.end();
// ... stdout parsing follows
}
Output Parsing and Message Handling
The Python runner parses line-delimited JSON from stdout (lines 99-124):
// From process-runner.ts lines 99-124
const rl = createInterface({ input: pythonProcess.stdout });
for await (const line of rl) {
let msg: PythonMessage;
try {
msg = JSON.parse(line);
} catch {
// Non-JSON lines treated as log output
onLog(line);
continue;
}
switch (msg.type) {
case 'progress':
onProgress(msg.pct, msg.label);
break;
case 'log':
onLog(msg.message);
break;
case 'done':
resolve(msg.result);
break;
case 'error':
reject(new Error(msg.error));
break;
}
}
Key Execution Differences Summary
| Aspect | JavaScript (ProcessRunner) |
Python (PythonProcessRunner) |
|---|---|---|
| Underlying mechanism | Node worker_threads |
child_process.spawn() |
| Process lifecycle | Single persistent worker, reused across calls | New process spawned per run() call |
| Startup cost | One-time worker creation; amortized over calls | Paid on every execution |
| Termination behavior | terminate() stops worker thread |
terminate() is no-op (process already exited) |
| Module isolation | Custom require with extension's node_modules |
Standard Python import path |
| Input channel | workerData + structured messages |
JSON line on stdin |
| Output channel | parentPort.postMessage() |
Line-delimited JSON on stdout |
| Error sources | Worker message or uncaught exception | JSON error message or non-zero exit code |
Practical Usage Examples
Running a JavaScript Extension
import { getProcessRunner } from './process-runner';
// Acquire or create runner (worker created lazily on first run)
const jsRunner = getProcessRunner(
'image-processor', // extension ID
'/extensions/image-proc', // extDir
'dist/processor.js', // entry file
'/workspace/project', // workspace directory
'/tmp/modly-run-123' // temporary directory
);
// Execute (reuses existing worker after first call)
const result = await jsRunner.run(
{ imagePath: '/workspace/input.png' },
{ quality: 85, format: 'webp' },
(pct, label) => console.log(`${pct}%: ${label}`),
(msg) => console.log(`[EXT] ${msg}`)
);
Running a Python Extension
import { getPythonProcessRunner, getExtPythonExe } from './process-runner';
// Locate Python in extension's virtual environment
const pythonExe = getExtPythonExe('/extensions/ml-model');
if (!pythonExe) throw new Error('Python not found in extension');
// Acquire runner (no persistent state between runs)
const pyRunner = getPythonProcessRunner(
'ml-inference',
pythonExe,
'/extensions/ml-model',
'inference.py',
'/workspace/project',
'/tmp/modly-run-456'
);
// Each run spawns fresh Python process
const result = await pyRunner.run(
{ data: [0.1, 0.2, 0.3] },
{ model: 'v2', batchSize: 32 },
(pct, label) => console.log(`${pct}%: ${label}`),
(msg) => console.log(`[PY] ${msg}`)
);
Resource Cleanup
import {
terminateProcessRunner,
terminateAllProcessRunners
} from './process-runner';
// Release specific JS worker (frees memory, terminates thread)
terminateProcessRunner('image-processor');
// Bulk cleanup on application shutdown
terminateAllProcessRunners();
Source File Reference
| File | Purpose |
|---|---|
electron/main/process-runner.ts |
Core ProcessRunner and PythonProcessRunner implementations with worker code, registry, and lifecycle management |
electron/main/ipc-handlers.ts |
IPC layer integration showing runner consumption patterns |
electron/main/python-bridge.ts |
getVenvPythonExe() and other Python environment utilities |
electron/main/extension-path-guard.ts |
Extension path validation before runner instantiation |
Summary
- JavaScript extensions execute in persistent Node worker threads that are created once and reused, minimizing startup overhead through the
ProcessRunnerclass - Python extensions spawn a fresh subprocess on every
run()call viaPythonProcessRunner, with input viastdinJSON and output via line-delimitedstdoutJSON - The message protocols differ architecturally: JavaScript uses structured
worker_threadsmessages; Python uses stream-based JSON lines - Termination behavior reflects lifecycle differences: JavaScript workers respond to
terminate(); Python processes self-terminate and ignore cleanup calls - Both implementations share the
IProcessRunnerinterface and registry pattern inelectron/main/process-runner.ts, providing unified caller semantics despite divergent internals
Frequently Asked Questions
Why doesn't Modly reuse Python processes like JavaScript workers?
Python's Global Interpreter Lock (GIL) and lack of native Node.js integration make persistent workers impractical. Fresh subprocesses prevent cross-run state contamination and align with Python's standard process model. The overhead is acceptable given typical Python extension use cases (heavy computation, ML inference) where execution time dominates startup cost.
How does error handling differ between JS and Python extensions?
JavaScript errors surface through worker.on('error') events or error messages from the worker thread, captured in run()'s Promise rejection. Python errors may appear as JSON error messages on stdout, non-zero exit codes, or process spawn failures—all converted to rejected Promises in PythonProcessRunner.
Can I control which Python executable runs my extension?
Yes. The getExtPythonExe() helper in python-bridge.ts locates Python within extension virtual environments. Pass an explicit pythonExe path to getPythonProcessRunner(), or rely on automatic detection that checks venv/bin/python (Unix) or venv\Scripts\python.exe (Windows) relative to the extension directory.
What happens if a JavaScript extension crashes?
Uncaught exceptions in the worker thread trigger the error event handler in ProcessRunner, rejecting the active run() Promise. The worker remains in a failed state; subsequent calls to run() will attempt to recreate the worker via ensureReady(). Use terminateProcessRunner() followed by fresh getProcessRunner() to force complete reset.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →