greed.js
Version:
Lightweight, private alternative to Colab. Run PyTorch & NumPy in browser with GPU acceleration (8.8x speedup). Fast, secure, runs locally.
553 lines (473 loc) • 14.6 kB
JavaScript
/**
* PyodideWorkerManager - Manages Pyodide Web Worker communication
*
* Provides a clean API for the main thread to communicate with the Pyodide worker
* Handles message passing, promise resolution, and error handling
*/
import EventEmitter from '../../core/event-emitter.js';
import logger from '../../utils/logger.js';
class PyodideWorkerManager extends EventEmitter {
constructor(config = {}) {
super();
this.config = {
workerURL: config.workerURL || null, // Will be set during initialization
pyodideIndexURL: config.pyodideIndexURL || 'https://cdn.jsdelivr.net/pyodide/v0.24.1/full/',
pyodideURL: config.pyodideURL || 'https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js',
preloadPackages: config.preloadPackages || ['numpy'],
timeout: config.timeout || 30000,
maxRetries: config.maxRetries || 3,
...config
};
// Worker state
this.worker = null;
this.isInitialized = false;
this.isReady = false;
this.installedPackages = new Set();
// Message handling
this.messageId = 0;
this.pendingMessages = new Map();
this.executionTasks = new Map();
// Performance tracking
this.stats = {
messagesProcessed: 0,
executionsCompleted: 0,
averageExecutionTime: 0,
totalExecutionTime: 0
};
}
/**
* Initialize the worker and Pyodide
*/
async initialize() {
if (this.isInitialized) {
return true;
}
try {
this.emit('init:start', { stage: 'worker' });
// Set up ready listener BEFORE creating worker
const readyPromise = new Promise((resolve, reject) => {
this._workerReadyResolve = resolve;
this._workerReadyReject = reject;
// Set timeout
this._workerReadyTimeout = setTimeout(() => {
logger.error('Worker ready timeout - no response after', this.config.timeout, 'ms');
reject(new Error('Worker ready timeout'));
}, this.config.timeout);
});
// Create the worker (will immediately send 'worker:ready')
await this._createWorker();
// Wait for worker ready signal (may have already arrived)
await readyPromise;
// Clear timeout
if (this._workerReadyTimeout) {
clearTimeout(this._workerReadyTimeout);
}
// Initialize Pyodide in the worker
await this._initializePyodide();
this.isInitialized = true;
this.isReady = true;
this.emit('init:complete', {
installedPackages: Array.from(this.installedPackages)
});
return true;
} catch (error) {
this.emit('init:error', { error });
throw error;
}
}
/**
* Create the Web Worker
*/
async _createWorker() {
return new Promise((resolve, reject) => {
try {
// Create worker from the pyodide-worker.js file
const workerURL = this.config.workerURL || this._getWorkerURL();
logger.info('Creating worker from URL:', workerURL);
// Note: Not using { type: 'module' } because webpack bundles as classic script
this.worker = new Worker(workerURL);
// Setup message handler
this.worker.onmessage = this._handleMessage.bind(this);
// Setup error handler
this.worker.onerror = (errorEvent) => {
const error = new Error(
`Worker error: ${errorEvent.message || 'Unknown error'} ` +
`at ${errorEvent.filename}:${errorEvent.lineno}:${errorEvent.colno}`
);
logger.error('Worker error event:', {
message: errorEvent.message,
filename: errorEvent.filename,
lineno: errorEvent.lineno,
colno: errorEvent.colno
});
this.emit('worker:error', { error });
// Also reject the ready promise if it's still pending
if (this._workerReadyReject) {
this._workerReadyReject(error);
this._workerReadyReject = null;
this._workerReadyResolve = null;
}
reject(error);
};
logger.info('Worker created successfully');
resolve();
} catch (error) {
logger.error('Failed to create worker:', error);
reject(error);
}
});
}
/**
* Get the worker URL (tries to use bundled worker or creates inline)
*/
_getWorkerURL() {
// Try different paths to find the worker
try {
// First try: Same directory as main bundle (for npm package usage)
if (typeof document !== 'undefined') {
// Get the base URL from greed.js script location
let baseURL;
// Find the greed.js script tag
const scripts = document.getElementsByTagName('script');
let greedScript = null;
for (let i = scripts.length - 1; i >= 0; i--) {
if (scripts[i].src && scripts[i].src.includes('greed')) {
greedScript = scripts[i];
break;
}
}
if (greedScript && greedScript.src) {
baseURL = greedScript.src.replace(/[^/]*$/, '');
logger.info('Worker path from greed.js script tag:', baseURL + 'pyodide-worker.js');
} else if (document.currentScript && document.currentScript.src) {
baseURL = document.currentScript.src.replace(/[^/]*$/, '');
logger.info('Worker path from currentScript:', baseURL + 'pyodide-worker.js');
} else {
// Fallback: try common patterns
baseURL = window.location.origin + '/dist/';
logger.info('Worker path from fallback:', baseURL + 'pyodide-worker.js');
}
const workerPath = baseURL + 'pyodide-worker.js';
return workerPath;
}
// Second try: Use relative URL (works when both files are served from same directory)
logger.info('Worker path (no document):', 'dist/pyodide-worker.js');
return 'dist/pyodide-worker.js';
} catch (error) {
logger.warn('Could not determine worker path, using fallback:', error);
// Fallback: try relative path
return 'dist/pyodide-worker.js';
}
}
/**
* Create an inline worker as fallback
*/
_createInlineWorker() {
// This is a fallback - in production, use the file-based worker
const blob = new Blob([
`importScripts('${this.config.pyodideURL}');
postMessage({ type: 'worker:ready' });`
], { type: 'application/javascript' });
return URL.createObjectURL(blob);
}
/**
* Initialize Pyodide in the worker
*/
async _initializePyodide() {
return this._sendMessage('init', {
config: {
indexURL: this.config.pyodideIndexURL,
pyodideURL: this.config.pyodideURL,
preloadPackages: this.config.preloadPackages
}
});
}
/**
* Handle messages from worker
*/
_handleMessage(event) {
const message = event.data;
this.stats.messagesProcessed++;
// Handle different message types
switch (message.type) {
case 'worker:ready':
// Resolve the ready promise
logger.info('Worker ready message received');
if (this._workerReadyResolve) {
this._workerReadyResolve();
this._workerReadyResolve = null;
this._workerReadyReject = null;
}
break;
case 'init:progress':
case 'init:complete':
case 'init:error':
case 'packages:loading':
case 'packages:loaded':
case 'packages:error':
case 'execution:warning':
case 'execution:interrupted':
case 'interrupt:error':
case 'reset:complete':
case 'reset:error':
// Forward events to listeners
this.emit(message.type, message);
break;
case 'execution:stdout':
// Forward streaming output to RuntimeManager
this.emit('execution:stdout', {
type: 'execution:stdout',
taskId: message.taskId,
output: message.output,
timestamp: message.timestamp
});
break;
case 'execution:complete':
this._handleExecutionComplete(message);
break;
case 'execution:error':
this._handleExecutionError(message);
break;
case 'execution:cleanup':
// Forward cleanup events
this.emit('execution:cleanup', message);
break;
case 'init:ack':
case 'loadPackages:ack':
case 'execute:ack':
case 'getGlobal:result':
case 'setGlobal:result':
case 'deleteGlobal:result':
case 'interrupt:ack':
case 'reset:ack':
case 'pong':
// Resolve pending messages
this._resolvePendingMessage(message.id, message);
break;
case 'error':
this._rejectPendingMessage(message.id, new Error(message.error.message));
break;
default:
logger.warn('Unknown message type from worker:', message.type);
}
}
/**
* Handle execution completion
*/
_handleExecutionComplete(message) {
const task = this.executionTasks.get(message.taskId);
if (task) {
const executionTime = performance.now() - task.startTime;
this.stats.executionsCompleted++;
this.stats.totalExecutionTime += executionTime;
this.stats.averageExecutionTime =
this.stats.totalExecutionTime / this.stats.executionsCompleted;
task.resolve(message.result);
this.executionTasks.delete(message.taskId);
this.emit('execution:complete', {
taskId: message.taskId,
executionTime,
result: message.result
});
}
}
/**
* Handle execution error
*/
_handleExecutionError(message) {
const task = this.executionTasks.get(message.taskId);
if (task) {
const error = new Error(message.error.message);
error.stack = message.error.stack;
error.pythonType = message.error.type;
task.reject(error);
this.executionTasks.delete(message.taskId);
this.emit('execution:error', {
taskId: message.taskId,
error
});
}
}
/**
* Send a message to the worker and wait for response
*/
async _sendMessage(type, data = {}) {
return new Promise((resolve, reject) => {
const id = this.messageId++;
const timeout = setTimeout(() => {
this.pendingMessages.delete(id);
reject(new Error(`Message timeout: ${type}`));
}, this.config.timeout);
this.pendingMessages.set(id, { resolve, reject, timeout });
this.worker.postMessage({
type,
id,
...data
});
});
}
/**
* Resolve a pending message
*/
_resolvePendingMessage(id, result) {
const pending = this.pendingMessages.get(id);
if (pending) {
clearTimeout(pending.timeout);
pending.resolve(result);
this.pendingMessages.delete(id);
}
}
/**
* Reject a pending message
*/
_rejectPendingMessage(id, error) {
const pending = this.pendingMessages.get(id);
if (pending) {
clearTimeout(pending.timeout);
pending.reject(error);
this.pendingMessages.delete(id);
}
}
/**
* Load Python packages
*/
async loadPackages(packages) {
if (!this.isReady) {
throw new Error('Worker not initialized');
}
const result = await this._sendMessage('loadPackages', { packages });
if (result.packages) {
result.packages.forEach(pkg => this.installedPackages.add(pkg));
}
return Array.from(this.installedPackages);
}
/**
* Execute Python code
*/
async executePython(code, options = {}) {
if (!this.isReady) {
throw new Error('Worker not initialized');
}
return new Promise((resolve, reject) => {
// Use taskId from options if provided, otherwise generate one
const taskId = options.taskId || `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const startTime = performance.now();
this.executionTasks.set(taskId, {
taskId,
startTime,
resolve,
reject
});
// Send execution request to worker
this.worker.postMessage({
type: 'execute',
id: this.messageId++,
taskId,
code,
options
});
// Setup timeout
if (options.timeout) {
setTimeout(() => {
if (this.executionTasks.has(taskId)) {
this.executionTasks.delete(taskId);
reject(new Error('Execution timeout'));
}
}, options.timeout);
}
});
}
/**
* Get a Python global variable
*/
async getGlobal(name) {
if (!this.isReady) {
throw new Error('Worker not initialized');
}
const result = await this._sendMessage('getGlobal', { name });
return result.value;
}
/**
* Set a Python global variable
*/
async setGlobal(name, value) {
if (!this.isReady) {
throw new Error('Worker not initialized');
}
const result = await this._sendMessage('setGlobal', { name, value });
return result.success;
}
/**
* Delete a Python global variable
*/
async deleteGlobal(name) {
if (!this.isReady) {
throw new Error('Worker not initialized');
}
const result = await this._sendMessage('deleteGlobal', { name });
return result.success;
}
/**
* Interrupt current execution
*/
async interrupt() {
if (!this.isReady) {
return;
}
await this._sendMessage('interrupt');
}
/**
* Reset the Python environment
*/
async reset() {
if (!this.isReady) {
return;
}
await this._sendMessage('reset');
}
/**
* Ping the worker to check if it's alive
*/
async ping() {
if (!this.isReady) {
return false;
}
try {
await this._sendMessage('ping');
return true;
} catch (error) {
return false;
}
}
/**
* Get worker statistics
*/
getStats() {
return { ...this.stats };
}
/**
* Terminate the worker
*/
async terminate() {
if (this.worker) {
// Reject all pending messages
for (const [id, pending] of this.pendingMessages) {
clearTimeout(pending.timeout);
pending.reject(new Error('Worker terminated'));
}
this.pendingMessages.clear();
// Reject all execution tasks
for (const [taskId, task] of this.executionTasks) {
task.reject(new Error('Worker terminated'));
}
this.executionTasks.clear();
// Terminate worker
this.worker.terminate();
this.worker = null;
this.isInitialized = false;
this.isReady = false;
this.emit('worker:terminated');
}
}
}
export default PyodideWorkerManager;