mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-24 09:39:35 +02:00
[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
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user