[pyodide] collect output files from worker and include in RunCodeResult
GitOrigin-RevId: fa9d501933ee32729e3d083183cd2a14ff969e95
This commit is contained in:
committed by
Copybot
parent
966a2f9bfe
commit
7eee5809bb
+5
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
OutputStream,
|
OutputStream,
|
||||||
|
OutputFileData,
|
||||||
ProjectFileData,
|
ProjectFileData,
|
||||||
PyodideWorkerRequest,
|
PyodideWorkerRequest,
|
||||||
PyodideWorkerResponse,
|
PyodideWorkerResponse,
|
||||||
@@ -20,7 +21,9 @@ export type LifecycleCallback = (
|
|||||||
type: 'run-finished'
|
type: 'run-finished'
|
||||||
fileId: string
|
fileId: string
|
||||||
executionId: string
|
executionId: string
|
||||||
|
success: boolean
|
||||||
outputs: string[]
|
outputs: string[]
|
||||||
|
outputFiles: OutputFileData[]
|
||||||
}
|
}
|
||||||
) => void
|
) => void
|
||||||
|
|
||||||
@@ -160,7 +163,9 @@ export class PyodideWorkerClient {
|
|||||||
type: 'run-finished',
|
type: 'run-finished',
|
||||||
fileId: response.fileId,
|
fileId: response.fileId,
|
||||||
executionId: response.executionId,
|
executionId: response.executionId,
|
||||||
|
success: response.success,
|
||||||
outputs: response.outputs,
|
outputs: response.outputs,
|
||||||
|
outputFiles: response.outputFiles,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -5,6 +5,11 @@ export type ProjectFileData = {
|
|||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OutputFileData = {
|
||||||
|
relativePath: string
|
||||||
|
content: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
// Main thread -> Worker messages
|
// Main thread -> Worker messages
|
||||||
|
|
||||||
export type InitRequest = {
|
export type InitRequest = {
|
||||||
@@ -48,7 +53,9 @@ export type RunCodeResult = {
|
|||||||
type: 'run-code-result'
|
type: 'run-code-result'
|
||||||
fileId: string
|
fileId: string
|
||||||
executionId: string
|
executionId: string
|
||||||
|
success: boolean
|
||||||
outputs: string[]
|
outputs: string[]
|
||||||
|
outputFiles: OutputFileData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PyodideWorkerResponse = PyodideWorkerEvent | RunCodeResult
|
export type PyodideWorkerResponse = PyodideWorkerEvent | RunCodeResult
|
||||||
|
|||||||
+44
-5
@@ -1,6 +1,8 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
import path from 'path-browserify'
|
import path from 'path-browserify'
|
||||||
import type { PyodideInterface } from 'pyodide'
|
import type { PyodideInterface } from 'pyodide'
|
||||||
import type {
|
import type {
|
||||||
|
OutputFileData,
|
||||||
ProjectFileData,
|
ProjectFileData,
|
||||||
PyodideWorkerRequest,
|
PyodideWorkerRequest,
|
||||||
RunCodeRequest,
|
RunCodeRequest,
|
||||||
@@ -98,7 +100,9 @@ async function handleRunCode(msg: RunCodeRequest) {
|
|||||||
type: 'run-code-result',
|
type: 'run-code-result',
|
||||||
fileId,
|
fileId,
|
||||||
executionId,
|
executionId,
|
||||||
|
success: false,
|
||||||
outputs: [],
|
outputs: [],
|
||||||
|
outputFiles: [],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -134,6 +138,7 @@ async function handleRunCode(msg: RunCodeRequest) {
|
|||||||
|
|
||||||
const fs = instance.FS
|
const fs = instance.FS
|
||||||
const originalWrite = fs.write as PyodideFS['write']
|
const originalWrite = fs.write as PyodideFS['write']
|
||||||
|
let runError: unknown = null
|
||||||
try {
|
try {
|
||||||
if (msg.files.length > 0) {
|
if (msg.files.length > 0) {
|
||||||
syncProjectFiles(fs, msg.files)
|
syncProjectFiles(fs, msg.files)
|
||||||
@@ -162,7 +167,14 @@ async function handleRunCode(msg: RunCodeRequest) {
|
|||||||
executionId,
|
executionId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (runError) {
|
} catch (e) {
|
||||||
|
runError = e
|
||||||
|
}
|
||||||
|
fs.write = originalWrite
|
||||||
|
|
||||||
|
const paths = [...writtenPaths]
|
||||||
|
|
||||||
|
if (runError) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
runError instanceof Error ? runError.message : String(runError)
|
runError instanceof Error ? runError.message : String(runError)
|
||||||
|
|
||||||
@@ -173,16 +185,43 @@ async function handleRunCode(msg: RunCodeRequest) {
|
|||||||
fileId,
|
fileId,
|
||||||
executionId,
|
executionId,
|
||||||
})
|
})
|
||||||
} finally {
|
|
||||||
fs.write = originalWrite
|
|
||||||
const outputs = [...writtenPaths].sort()
|
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: 'run-code-result',
|
type: 'run-code-result',
|
||||||
fileId,
|
fileId,
|
||||||
executionId,
|
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 => {
|
self.addEventListener('message', async event => {
|
||||||
|
|||||||
+133
-22
@@ -1,5 +1,8 @@
|
|||||||
import { expect } from 'chai'
|
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'
|
import { WorkerMock, createWorker } from './worker-mock'
|
||||||
|
|
||||||
const BASE_ASSET_PATH = 'https://assets.example.test/'
|
const BASE_ASSET_PATH = 'https://assets.example.test/'
|
||||||
@@ -67,12 +70,7 @@ describe('PyodideWorkerClient', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function setupClientWithLifecycleTracking() {
|
function setupClientWithLifecycleTracking() {
|
||||||
const lifecycleEvents: {
|
const lifecycleEvents: Parameters<LifecycleCallback>[0][] = []
|
||||||
type: string
|
|
||||||
fileId?: string
|
|
||||||
executionId?: string
|
|
||||||
outputs?: string[]
|
|
||||||
}[] = []
|
|
||||||
|
|
||||||
const client = new PyodideWorkerClient({
|
const client = new PyodideWorkerClient({
|
||||||
baseAssetPath: BASE_ASSET_PATH,
|
baseAssetPath: BASE_ASSET_PATH,
|
||||||
@@ -99,15 +97,21 @@ describe('PyodideWorkerClient', function () {
|
|||||||
type: 'run-code-result',
|
type: 'run-code-result',
|
||||||
fileId: 'main.py',
|
fileId: 'main.py',
|
||||||
executionId: 'exec-3',
|
executionId: 'exec-3',
|
||||||
|
success: true,
|
||||||
outputs: ['/project/output.txt'],
|
outputs: ['/project/output.txt'],
|
||||||
|
outputFiles: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(lifecycleEvents).to.deep.include({
|
expect(lifecycleEvents).to.deep.equal([
|
||||||
type: 'run-finished',
|
{
|
||||||
fileId: 'main.py',
|
type: 'run-finished',
|
||||||
executionId: 'exec-3',
|
fileId: 'main.py',
|
||||||
outputs: ['/project/output.txt'],
|
executionId: 'exec-3',
|
||||||
})
|
success: true,
|
||||||
|
outputs: ['/project/output.txt'],
|
||||||
|
outputFiles: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('surfaces outputs array from run-code-result with multiple files', function () {
|
it('surfaces outputs array from run-code-result with multiple files', function () {
|
||||||
@@ -123,15 +127,21 @@ describe('PyodideWorkerClient', function () {
|
|||||||
type: 'run-code-result',
|
type: 'run-code-result',
|
||||||
fileId: 'main.py',
|
fileId: 'main.py',
|
||||||
executionId: 'exec-4',
|
executionId: 'exec-4',
|
||||||
|
success: true,
|
||||||
outputs: ['/project/fig1.png', '/project/results/data.csv'],
|
outputs: ['/project/fig1.png', '/project/results/data.csv'],
|
||||||
|
outputFiles: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(lifecycleEvents).to.deep.include({
|
expect(lifecycleEvents).to.deep.equal([
|
||||||
type: 'run-finished',
|
{
|
||||||
fileId: 'main.py',
|
type: 'run-finished',
|
||||||
executionId: 'exec-4',
|
fileId: 'main.py',
|
||||||
outputs: ['/project/fig1.png', '/project/results/data.csv'],
|
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 () {
|
it('surfaces empty outputs when no project files were written', function () {
|
||||||
@@ -147,19 +157,120 @@ describe('PyodideWorkerClient', function () {
|
|||||||
type: 'run-code-result',
|
type: 'run-code-result',
|
||||||
fileId: 'main.py',
|
fileId: 'main.py',
|
||||||
executionId: 'exec-5',
|
executionId: 'exec-5',
|
||||||
|
success: true,
|
||||||
outputs: [],
|
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',
|
type: 'run-finished',
|
||||||
fileId: 'main.py',
|
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: [],
|
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 () {
|
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({
|
const client = new PyodideWorkerClient({
|
||||||
baseAssetPath: BASE_ASSET_PATH,
|
baseAssetPath: BASE_ASSET_PATH,
|
||||||
|
|||||||
Reference in New Issue
Block a user