Преглед изворни кода

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

GitOrigin-RevId: fa9d501933ee32729e3d083183cd2a14ff969e95
Domagoj Kriskovic пре 5 месеци
родитељ
комит
7eee5809bb

+ 5 - 0
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,
         })
     }
   }

+ 7 - 0
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

+ 44 - 5
services/web/frontend/js/features/ide-react/components/editor/python/pyodide.worker.ts

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

+ 133 - 22
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<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.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-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: [],
     })
 
-    expect(lifecycleEvents).to.deep.include({
+    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-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,