[pyodide] collect output files from worker and include in RunCodeResult

GitOrigin-RevId: fa9d501933ee32729e3d083183cd2a14ff969e95
This commit is contained in:
Domagoj Kriskovic
2026-04-23 10:49:03 +02:00
committed by Copybot
parent 966a2f9bfe
commit 7eee5809bb
4 changed files with 189 additions and 27 deletions

View File

@@ -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,
})
}
}

View File

@@ -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

View File

@@ -1,6 +1,8 @@
/// <reference lib="webworker" />
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 => {

View File

@@ -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<LifecycleCallback>[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,