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,