[web] include output tracking in run-finished lifecycle event in pyodide

GitOrigin-RevId: 5c72abc35ea4045f9c6aa374a2c51490f8f6cd38
This commit is contained in:
Domagoj Kriskovic
2026-04-09 10:33:16 +02:00
committed by Copybot
parent b893ba36e2
commit 7d032e73d6
4 changed files with 80 additions and 13 deletions

View File

@@ -14,7 +14,7 @@ export type LifecycleCallback = (
event: event:
| { type: 'loaded' } | { type: 'loaded' }
| { type: 'loading-failed'; error: string } | { type: 'loading-failed'; error: string }
| { type: 'run-finished'; requestId: string } | { type: 'run-finished'; requestId: string; outputs: string[] }
) => void ) => void
export class PyodideWorkerClient { export class PyodideWorkerClient {
@@ -172,6 +172,7 @@ export class PyodideWorkerClient {
this.lifecycleCallback?.({ this.lifecycleCallback?.({
type: 'run-finished', type: 'run-finished',
requestId: response.id, requestId: response.id,
outputs: response.outputs,
}) })
break break
} }

View File

@@ -43,6 +43,7 @@ export type PyodideWorkerEvent =
export type RunCodeResult = { export type RunCodeResult = {
type: 'run-code-result' type: 'run-code-result'
id: string id: string
outputs: string[]
} }
export type PyodideWorkerResponse = PyodideWorkerEvent | RunCodeResult export type PyodideWorkerResponse = PyodideWorkerEvent | RunCodeResult

View File

@@ -10,6 +10,7 @@ type PyodideFS = PyodideInterface['FS']
type PyodideModule = typeof import('pyodide') type PyodideModule = typeof import('pyodide')
const PROJECT_FS_ROOT = '/project' const PROJECT_FS_ROOT = '/project'
const PROJECT_FS_PREFIX = `${PROJECT_FS_ROOT}/`
const PYODIDE_INDEX_PATH = 'js/libs/pyodide/' const PYODIDE_INDEX_PATH = 'js/libs/pyodide/'
function ensureDirectoryExists(fs: PyodideFS, filePath: string) { function ensureDirectoryExists(fs: PyodideFS, filePath: string) {
@@ -94,6 +95,7 @@ async function handleRunCode(msg: RunCodeRequest) {
self.postMessage({ self.postMessage({
type: 'run-code-result', type: 'run-code-result',
id: msg.id, id: msg.id,
outputs: [],
}) })
return return
} }
@@ -102,6 +104,8 @@ async function handleRunCode(msg: RunCodeRequest) {
env: { MPLBACKEND: 'Agg' }, env: { MPLBACKEND: 'Agg' },
}) })
const writtenPaths = new Set<string>()
instance.setStdout({ instance.setStdout({
batched: (line: string) => { batched: (line: string) => {
self.postMessage({ self.postMessage({
@@ -124,11 +128,25 @@ async function handleRunCode(msg: RunCodeRequest) {
}) })
try { try {
const fs = instance.FS
if (msg.files.length > 0) { if (msg.files.length > 0) {
const fs = instance.FS
syncProjectFiles(fs, msg.files) syncProjectFiles(fs, msg.files)
} }
const originalWrite = fs.write as PyodideFS['write']
fs.write = ((...args: Parameters<PyodideFS['write']>) => {
const [stream] = args
// Only surface writes to the synced project workspace, not Pyodide internals.
if (
typeof stream?.path === 'string' &&
stream.path.startsWith(PROJECT_FS_PREFIX)
) {
writtenPaths.add(stream.path)
}
return originalWrite(...args)
}) as PyodideFS['write']
const result = await instance.runPythonAsync(msg.code) const result = await instance.runPythonAsync(msg.code)
if (result !== undefined) { if (result !== undefined) {
self.postMessage({ self.postMessage({
@@ -148,12 +166,14 @@ async function handleRunCode(msg: RunCodeRequest) {
line: errorMessage, line: errorMessage,
requestId: msg.id, requestId: msg.id,
}) })
} finally {
const outputs = [...writtenPaths].sort()
self.postMessage({
type: 'run-code-result',
id: msg.id,
outputs,
})
} }
self.postMessage({
type: 'run-code-result',
id: msg.id,
})
} }
self.addEventListener('message', async event => { self.addEventListener('message', async event => {

View File

@@ -97,25 +97,70 @@ describe('PyodideWorkerClient', function () {
expect(runRequest).to.include({ type: 'run-code', id: 'boom.py' }) expect(runRequest).to.include({ type: 'run-code', id: 'boom.py' })
}) })
it('emits run-finished lifecycle event from run-code-result', function () { function setupClientWithLifecycleTracking() {
const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH }) const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
const worker = WorkerMock.instances[0] const worker = WorkerMock.instances[0]
const lifecycleEvents: Array<{ type: string; requestId?: string }> = [] const lifecycleEvents: Array<{
type: string
client.setLifecycleCallback(event => { requestId?: string
lifecycleEvents.push(event) outputs?: string[]
}) }> = []
client.setLifecycleCallback(event => lifecycleEvents.push(event))
worker.emitMessage({ type: 'listening' }) worker.emitMessage({ type: 'listening' })
return { client, worker, lifecycleEvents }
}
it('emits run-finished lifecycle event from run-code-result', function () {
const { client, worker, lifecycleEvents } =
setupClientWithLifecycleTracking()
client.runCode('print("ok")', { requestId: 'main.py', files: [] }) client.runCode('print("ok")', { requestId: 'main.py', files: [] })
worker.emitMessage({ worker.emitMessage({
type: 'run-code-result', type: 'run-code-result',
id: 'main.py', id: 'main.py',
outputs: ['/project/output.txt'],
}) })
expect(lifecycleEvents).to.deep.include({ expect(lifecycleEvents).to.deep.include({
type: 'run-finished', type: 'run-finished',
requestId: 'main.py', requestId: 'main.py',
outputs: ['/project/output.txt'],
})
})
it('surfaces outputs array from run-code-result with multiple files', function () {
const { client, worker, lifecycleEvents } =
setupClientWithLifecycleTracking()
client.runCode('write_files()', { requestId: 'main.py', files: [] })
worker.emitMessage({
type: 'run-code-result',
id: 'main.py',
outputs: ['/project/fig1.png', '/project/results/data.csv'],
})
expect(lifecycleEvents).to.deep.include({
type: 'run-finished',
requestId: 'main.py',
outputs: ['/project/fig1.png', '/project/results/data.csv'],
})
})
it('surfaces empty outputs when no project files were written', function () {
const { client, worker, lifecycleEvents } =
setupClientWithLifecycleTracking()
client.runCode('print("no writes")', { requestId: 'main.py', files: [] })
worker.emitMessage({
type: 'run-code-result',
id: 'main.py',
outputs: [],
})
expect(lifecycleEvents).to.deep.include({
type: 'run-finished',
requestId: 'main.py',
outputs: [],
}) })
}) })