diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts index c16a745a15..c79caeaf8d 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts +++ b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts @@ -1,5 +1,6 @@ import type { OutputStream, + OutputFileData, ProjectFileData, PyodideWorkerRequest, PyodideWorkerResponse, @@ -20,7 +21,9 @@ export type LifecycleCallback = ( type: 'run-finished' fileId: string executionId: string + success: boolean outputs: string[] + outputFiles: OutputFileData[] } ) => void @@ -160,7 +163,9 @@ export class PyodideWorkerClient { type: 'run-finished', fileId: response.fileId, executionId: response.executionId, + success: response.success, outputs: response.outputs, + outputFiles: response.outputFiles, }) } } diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts index 223021fa02..8b5f08ad0b 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts +++ b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts @@ -5,6 +5,11 @@ export type ProjectFileData = { content: string } +export type OutputFileData = { + relativePath: string + content: Uint8Array +} + // Main thread -> Worker messages export type InitRequest = { @@ -48,7 +53,9 @@ export type RunCodeResult = { type: 'run-code-result' fileId: string executionId: string + success: boolean outputs: string[] + outputFiles: OutputFileData[] } export type PyodideWorkerResponse = PyodideWorkerEvent | RunCodeResult diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts index 5254f50eb2..55a4d61ac3 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts +++ b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts @@ -1,6 +1,8 @@ +/// import path from 'path-browserify' import type { PyodideInterface } from 'pyodide' import type { + OutputFileData, ProjectFileData, PyodideWorkerRequest, RunCodeRequest, @@ -98,7 +100,9 @@ async function handleRunCode(msg: RunCodeRequest) { type: 'run-code-result', fileId, executionId, + success: false, outputs: [], + outputFiles: [], }) return } @@ -134,6 +138,7 @@ async function handleRunCode(msg: RunCodeRequest) { const fs = instance.FS const originalWrite = fs.write as PyodideFS['write'] + let runError: unknown = null try { if (msg.files.length > 0) { syncProjectFiles(fs, msg.files) @@ -162,7 +167,14 @@ async function handleRunCode(msg: RunCodeRequest) { executionId, }) } - } catch (runError) { + } catch (e) { + runError = e + } + fs.write = originalWrite + + const paths = [...writtenPaths] + + if (runError) { const errorMessage = runError instanceof Error ? runError.message : String(runError) @@ -173,16 +185,43 @@ async function handleRunCode(msg: RunCodeRequest) { fileId, executionId, }) - } finally { - fs.write = originalWrite - const outputs = [...writtenPaths].sort() self.postMessage({ type: 'run-code-result', fileId, executionId, - outputs, + success: false, + outputs: paths, + outputFiles: [], }) + return } + + const outputFiles: OutputFileData[] = [] + const transferables: Transferable[] = [] + for (const writtenPath of paths) { + const content = fs.readFile(writtenPath) + const relativePath = writtenPath.slice(PROJECT_FS_PREFIX.length) + outputFiles.push({ relativePath, content }) + if (content.buffer instanceof ArrayBuffer) { + transferables.push(content.buffer) + } + } + + // The transferables moves ownership of each ArrayBuffer to the main thread + // instead of structured-cloning it. The buffers are already referenced from + // outputFiles.content; listing them here just swaps copy for move, so file + // contents travel through once rather than being allocated on both sides. + self.postMessage( + { + type: 'run-code-result', + fileId, + executionId, + success: true, + outputs: paths, + outputFiles, + }, + transferables + ) } self.addEventListener('message', async event => { diff --git a/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts b/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts index c0f3f69fc7..be6b684d5d 100644 --- a/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts +++ b/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts @@ -1,5 +1,8 @@ import { expect } from 'chai' -import { PyodideWorkerClient } from '@/features/ide-react/components/editor/python/pyodide-worker-client' +import { + PyodideWorkerClient, + type LifecycleCallback, +} from '@/features/ide-react/components/editor/python/pyodide-worker-client' import { WorkerMock, createWorker } from './worker-mock' const BASE_ASSET_PATH = 'https://assets.example.test/' @@ -67,12 +70,7 @@ describe('PyodideWorkerClient', function () { }) function setupClientWithLifecycleTracking() { - const lifecycleEvents: { - type: string - fileId?: string - executionId?: string - outputs?: string[] - }[] = [] + const lifecycleEvents: Parameters[0][] = [] const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH, @@ -99,15 +97,21 @@ describe('PyodideWorkerClient', function () { type: 'run-code-result', fileId: 'main.py', executionId: 'exec-3', + success: true, outputs: ['/project/output.txt'], + outputFiles: [], }) - expect(lifecycleEvents).to.deep.include({ - type: 'run-finished', - fileId: 'main.py', - executionId: 'exec-3', - outputs: ['/project/output.txt'], - }) + expect(lifecycleEvents).to.deep.equal([ + { + type: 'run-finished', + fileId: 'main.py', + executionId: 'exec-3', + success: true, + outputs: ['/project/output.txt'], + outputFiles: [], + }, + ]) }) it('surfaces outputs array from run-code-result with multiple files', function () { @@ -123,15 +127,21 @@ describe('PyodideWorkerClient', function () { type: 'run-code-result', fileId: 'main.py', executionId: 'exec-4', + success: true, outputs: ['/project/fig1.png', '/project/results/data.csv'], + outputFiles: [], }) - expect(lifecycleEvents).to.deep.include({ - type: 'run-finished', - fileId: 'main.py', - executionId: 'exec-4', - outputs: ['/project/fig1.png', '/project/results/data.csv'], - }) + expect(lifecycleEvents).to.deep.equal([ + { + type: 'run-finished', + fileId: 'main.py', + executionId: 'exec-4', + success: true, + outputs: ['/project/fig1.png', '/project/results/data.csv'], + outputFiles: [], + }, + ]) }) it('surfaces empty outputs when no project files were written', function () { @@ -147,19 +157,120 @@ describe('PyodideWorkerClient', function () { type: 'run-code-result', fileId: 'main.py', executionId: 'exec-5', + success: true, outputs: [], + outputFiles: [], }) - expect(lifecycleEvents).to.deep.include({ + expect(lifecycleEvents).to.deep.equal([ + { + type: 'run-finished', + fileId: 'main.py', + executionId: 'exec-5', + success: true, + outputs: [], + outputFiles: [], + }, + ]) + }) + + it('surfaces success and outputFiles from run-code-result', function () { + const { client, worker, lifecycleEvents } = + setupClientWithLifecycleTracking() + + client.runCode('write_files()', { + fileId: 'main.py', + executionId: 'exec-success', + files: [], + }) + const csvContent = new Uint8Array([1, 2, 3]) + const pngContent = new Uint8Array([4, 5, 6, 7]) + worker.emitMessage({ + type: 'run-code-result', + fileId: 'main.py', + executionId: 'exec-success', + success: true, + outputs: ['/project/data.csv', '/project/plot.png'], + outputFiles: [ + { relativePath: 'data.csv', content: csvContent }, + { relativePath: 'plot.png', content: pngContent }, + ], + }) + + const finished = lifecycleEvents.find(e => e.type === 'run-finished') + expect(finished).to.deep.equal({ type: 'run-finished', fileId: 'main.py', - executionId: 'exec-5', + executionId: 'exec-success', + success: true, + outputs: ['/project/data.csv', '/project/plot.png'], + outputFiles: [ + { relativePath: 'data.csv', content: csvContent }, + { relativePath: 'plot.png', content: pngContent }, + ], + }) + }) + + it('surfaces success: false with empty outputFiles on script error', function () { + const { client, worker, lifecycleEvents } = + setupClientWithLifecycleTracking() + + client.runCode('raise RuntimeError("boom")', { + fileId: 'main.py', + executionId: 'exec-error', + files: [], + }) + worker.emitMessage({ + type: 'run-code-result', + fileId: 'main.py', + executionId: 'exec-error', + success: false, outputs: [], + outputFiles: [], + }) + + const finished = lifecycleEvents.find(e => e.type === 'run-finished') + expect(finished).to.deep.equal({ + type: 'run-finished', + fileId: 'main.py', + executionId: 'exec-error', + success: false, + outputs: [], + outputFiles: [], + }) + }) + + it('surfaces empty outputFiles when success but no files were written', function () { + const { client, worker, lifecycleEvents } = + setupClientWithLifecycleTracking() + + client.runCode('print("no writes")', { + fileId: 'main.py', + executionId: 'exec-nowrites', + files: [], + }) + worker.emitMessage({ + type: 'run-code-result', + fileId: 'main.py', + executionId: 'exec-nowrites', + success: true, + outputs: [], + outputFiles: [], + }) + + const finished = lifecycleEvents.find(e => e.type === 'run-finished') + expect(finished).to.deep.equal({ + type: 'run-finished', + fileId: 'main.py', + executionId: 'exec-nowrites', + success: true, + outputs: [], + outputFiles: [], }) }) it('reports lifecycle failure and rejects future run requests when loading fails', function () { - const lifecycleEvents: Array<{ type: string; error?: string }> = [] + const lifecycleEvents: { type: string; error?: string }[] = [] const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH,