Quellcode durchsuchen

[web] Add PythonExecutionContext with per-file output buffers (#32737)

* [web] extract PythonExecutionContext and PythonRunner to manage pyodide execution per file

* [web] define worker URL in python execution context in order to avoid breaking cjs-based tests

* [web] use null check for doc contents to allow running empty python files

* [web] flush buffered editor ops before refreshing snapshot for python execution

* [web] catch getExecutionContext errors in python runner to prevent unhandled rejections

* [web] add PythonRunner unit tests and extract shared WorkerMock

* refactor: rename snapshot to state in PythonRunner

* fix: remove unnecessary path normalization in PythonExecutionProvider

* fix cypress tests

GitOrigin-RevId: 9c55586d982fe8df5b90374227005c6b83e94d1f
Domagoj Kriskovic vor 3 Monaten
Ursprung
Commit
415db24ba4

+ 0 - 1
services/web/frontend/extracted-translations.json

@@ -606,7 +606,6 @@
   "essential_cookies_only": "",
   "event_type": "",
   "example_project": "",
-  "execution_stopped": "",
   "existing_plan_active_until_term_end": "",
   "expand": "",
   "experiment_full_check_back_soon": "",

+ 32 - 46
services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts

@@ -7,30 +7,45 @@ import type {
 export type OutputCallback = (
   stream: 'stdout' | 'stderr',
   line: string,
-  requestId?: string
+  fileId: string,
+  executionId: string
 ) => void
 
 export type LifecycleCallback = (
   event:
     | { type: 'loaded' }
     | { type: 'loading-failed'; error: string }
-    | { type: 'run-finished'; requestId: string; outputs: string[] }
+    | {
+        type: 'run-finished'
+        fileId: string
+        executionId: string
+        outputs: string[]
+      }
 ) => void
 
 export class PyodideWorkerClient {
   private worker: Worker
   private baseAssetPath: string
+  private createWorker: () => Worker
   private listening = false
-  private loaded = false
   private destroyed = false
   private loadingError: string | null = null
   private pendingMessages: PyodideWorkerRequest[] = []
-  private outputCallback: OutputCallback | null = null
-  private lifecycleCallback: LifecycleCallback | null = null
-
-  constructor(options: { baseAssetPath: string }) {
+  private outputCallback: OutputCallback | null
+  private lifecycleCallback: LifecycleCallback | null
+
+  constructor(options: {
+    baseAssetPath: string
+    createWorker: () => Worker
+    onOutput?: OutputCallback
+    onLifecycle?: LifecycleCallback
+  }) {
     this.baseAssetPath = options.baseAssetPath
+    this.createWorker = options.createWorker
+    this.outputCallback = options.onOutput ?? null
+    this.lifecycleCallback = options.onLifecycle ?? null
     this.worker = this.createWorker()
+    this.worker.addEventListener('message', this.receive)
 
     this.queueMessage({
       type: 'init',
@@ -38,25 +53,9 @@ export class PyodideWorkerClient {
     })
   }
 
-  setOutputCallback(callback: OutputCallback) {
-    this.outputCallback = callback
-  }
-
-  setLifecycleCallback(handler: LifecycleCallback) {
-    this.lifecycleCallback = handler
-
-    if (this.loaded) {
-      handler({ type: 'loaded' })
-      return
-    }
-    if (this.loadingError) {
-      handler({ type: 'loading-failed', error: this.loadingError })
-    }
-  }
-
   runCode(
     code: string,
-    options: { requestId: string; files: ProjectFileData[] }
+    options: { fileId: string; executionId: string; files: ProjectFileData[] }
   ): void {
     if (this.destroyed) {
       throw new Error('Pyodide worker client has been destroyed')
@@ -69,12 +68,13 @@ export class PyodideWorkerClient {
     this.queueMessage({
       type: 'run-code',
       code,
-      id: options.requestId,
+      fileId: options.fileId,
+      executionId: options.executionId,
       files: options.files,
     })
   }
 
-  stop(): void {
+  reset(): void {
     if (this.destroyed) {
       return
     }
@@ -85,11 +85,11 @@ export class PyodideWorkerClient {
 
     // Reset state for the new worker
     this.listening = false
-    this.loaded = false
     this.loadingError = null
 
     // Create a fresh worker and re-initialize Pyodide
     this.worker = this.createWorker()
+    this.worker.addEventListener('message', this.receive)
     this.queueMessage({
       type: 'init',
       baseAssetPath: this.baseAssetPath,
@@ -104,23 +104,9 @@ export class PyodideWorkerClient {
     this.destroyed = true
     this.pendingMessages.length = 0
 
-    if (!this.loaded && !this.loadingError) {
-      this.loadingError = 'Pyodide worker was destroyed before loading finished'
-    }
-
     this.worker.terminate()
   }
 
-  private createWorker(): Worker {
-    const worker = new Worker(
-      /* webpackChunkName: "pyodide-worker" */
-      new URL('./pyodide.worker.ts', import.meta.url),
-      { type: 'module' }
-    )
-    worker.addEventListener('message', this.receive)
-    return worker
-  }
-
   private queueMessage(message: PyodideWorkerRequest) {
     if (this.listening) {
       this.worker.postMessage(message)
@@ -147,7 +133,6 @@ export class PyodideWorkerClient {
         return
 
       case 'loaded':
-        this.loaded = true
         this.lifecycleCallback?.({ type: 'loaded' })
         return
 
@@ -164,17 +149,18 @@ export class PyodideWorkerClient {
         this.outputCallback?.(
           response.stream,
           response.line,
-          response.requestId
+          response.fileId,
+          response.executionId
         )
-        break
+        return
 
       case 'run-code-result':
         this.lifecycleCallback?.({
           type: 'run-finished',
-          requestId: response.id,
+          fileId: response.fileId,
+          executionId: response.executionId,
           outputs: response.outputs,
         })
-        break
     }
   }
 }

+ 6 - 3
services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-messages.ts

@@ -12,7 +12,8 @@ export type InitRequest = {
 
 export type RunCodeRequest = {
   type: 'run-code'
-  id: string
+  fileId: string
+  executionId: string
   code: string
   files: ProjectFileData[]
 }
@@ -29,7 +30,8 @@ export type OutputLineEvent = {
   type: 'output-line'
   stream: 'stdout' | 'stderr'
   line: string
-  requestId?: string
+  fileId: string
+  executionId: string
 }
 
 export type PyodideWorkerEvent =
@@ -42,7 +44,8 @@ export type PyodideWorkerEvent =
 
 export type RunCodeResult = {
   type: 'run-code-result'
-  id: string
+  fileId: string
+  executionId: string
   outputs: string[]
 }
 

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

@@ -84,17 +84,20 @@ async function handleInit(msg: { baseAssetPath: string }) {
 }
 
 async function handleRunCode(msg: RunCodeRequest) {
+  const { fileId, executionId } = msg
+
   if (!pyodideModule) {
-    const error = 'Pyodide is not initialized'
     self.postMessage({
       type: 'output-line',
       stream: 'stderr',
-      line: error,
-      requestId: msg.id,
+      line: 'Pyodide is not initialized',
+      fileId,
+      executionId,
     })
     self.postMessage({
       type: 'run-code-result',
-      id: msg.id,
+      fileId,
+      executionId,
       outputs: [],
     })
     return
@@ -112,7 +115,8 @@ async function handleRunCode(msg: RunCodeRequest) {
         type: 'output-line',
         stream: 'stdout',
         line,
-        requestId: msg.id,
+        fileId,
+        executionId,
       })
     },
   })
@@ -122,7 +126,8 @@ async function handleRunCode(msg: RunCodeRequest) {
         type: 'output-line',
         stream: 'stderr',
         line,
-        requestId: msg.id,
+        fileId,
+        executionId,
       })
     },
   })
@@ -153,7 +158,8 @@ async function handleRunCode(msg: RunCodeRequest) {
         type: 'output-line',
         stream: 'stdout',
         line: String(result),
-        requestId: msg.id,
+        fileId,
+        executionId,
       })
     }
   } catch (runError) {
@@ -164,14 +170,16 @@ async function handleRunCode(msg: RunCodeRequest) {
       type: 'output-line',
       stream: 'stderr',
       line: errorMessage,
-      requestId: msg.id,
+      fileId,
+      executionId,
     })
   } finally {
     fs.write = originalWrite
     const outputs = [...writtenPaths].sort()
     self.postMessage({
       type: 'run-code-result',
-      id: msg.id,
+      fileId,
+      executionId,
       outputs,
     })
   }

+ 35 - 177
services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx

@@ -1,174 +1,32 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useMemo, useSyncExternalStore } from 'react'
 import { useTranslation } from 'react-i18next'
-import path from 'path-browserify'
 import OLButton from '@/shared/components/ol/ol-button'
 import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar'
 import MaterialIcon from '@/shared/components/material-icon'
 import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
-import { useProjectContext } from '@/shared/context/project-context'
-import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
-import { debugConsole } from '@/utils/debugging'
-import getMeta from '@/utils/meta'
-import { PyodideWorkerClient } from './pyodide-worker-client'
+import { usePythonExecutionContext } from '@/features/ide-react/context/python-execution-context'
+import { DEFAULT_STATE } from './python-runner'
+
+const emptySubscribe = () => () => {}
+const getDefaultState = () => DEFAULT_STATE
 
 export default function PythonOutputPane() {
   const { t } = useTranslation()
-  const { currentDocument, currentDocumentId } = useEditorOpenDocContext()
-  const { pathInFolder } = useFileTreePathContext()
-  const { projectSnapshot } = useProjectContext()
-  const clientRef = useRef<PyodideWorkerClient | null>(null)
-  const currentRequestIdRef = useRef<string | null>(null)
-  const [isReady, setIsReady] = useState(false)
-  const [output, setOutput] = useState<string[]>([])
-  const [isRunning, setIsRunning] = useState(false)
-  const [error, setError] = useState<string | null>(null)
-  const [isLoadingPyodide, setIsLoadingPyodide] = useState(true)
-
-  const appendOutput = useCallback((line: string) => {
-    setOutput(previousOutput => [...previousOutput, line])
-  }, [])
-
-  useEffect(() => {
-    const baseAssetPath = new URL(
-      getMeta('ol-baseAssetPath'),
-      window.location.href
-    ).toString()
-    const client = new PyodideWorkerClient({ baseAssetPath })
-    clientRef.current = client
-    let cancelled = false
-
-    client.setLifecycleCallback(event => {
-      if (cancelled) {
-        return
-      }
-
-      switch (event.type) {
-        case 'loaded':
-          setIsReady(true)
-          setIsLoadingPyodide(false)
-          return
-
-        case 'loading-failed':
-          debugConsole.error('Failed to load Python runtime', event.error)
-          setIsLoadingPyodide(false)
-          setError(formatError(event.error))
-          setIsRunning(false)
-          return
-
-        case 'run-finished':
-          if (event.requestId !== currentRequestIdRef.current) {
-            return
-          }
-          currentRequestIdRef.current = null
-          setIsRunning(false)
-          break
-      }
-    })
-
-    client.setOutputCallback((_stream, line, requestId) => {
-      if (!requestId || requestId !== currentRequestIdRef.current) {
-        return
-      }
-      appendOutput(line)
-    })
-
-    return () => {
-      cancelled = true
-      currentRequestIdRef.current = null
-      client.destroy()
-      clientRef.current = null
-    }
-  }, [appendOutput])
-
-  useEffect(() => {
-    currentRequestIdRef.current = null
-    setIsRunning(false)
-    setOutput([])
-    setError(null)
-  }, [currentDocumentId])
-
-  const buildCurrentDocumentSyncFile = useCallback(() => {
-    if (!currentDocument || !currentDocumentId) {
-      return null
-    }
-
-    const content = currentDocument.getSnapshot()
-    if (typeof content !== 'string') {
-      return null
-    }
-
-    const currentPath = pathInFolder(currentDocumentId)
-    if (!currentPath) {
-      throw new Error(
-        'Unable to resolve current document path for Python sync.'
-      )
-    }
-
-    return {
-      relativePath: path.posix.normalize(currentPath),
-      content,
-    }
-  }, [currentDocument, currentDocumentId, pathInFolder])
-
-  const getLatestProjectFiles = useCallback(async () => {
-    await projectSnapshot.refresh()
-    return projectSnapshot.getDocPaths().reduce(
-      (files, relativePath) => {
-        const content = projectSnapshot.getDocContents(relativePath)
-        if (content !== null) {
-          files.push({ relativePath, content })
-        }
-        return files
-      },
-      [] as { relativePath: string; content: string }[]
-    )
-  }, [projectSnapshot])
-
-  const handleRun = useCallback(async () => {
-    const client = clientRef.current
-    if (!client || !isReady) {
-      return
-    }
-
-    const syncFile = buildCurrentDocumentSyncFile()
-    if (!syncFile) {
-      return
-    }
-
-    setOutput([])
-    setError(null)
-
-    const requestId = syncFile.relativePath
-    currentRequestIdRef.current = requestId
-    setIsRunning(true)
-
-    const isCancelled = () => currentRequestIdRef.current !== requestId
-
-    try {
-      const files = await getLatestProjectFiles()
-      if (isCancelled()) return
-      client.runCode(syncFile.content, { requestId, files })
-    } catch (runError) {
-      if (isCancelled()) return
-      currentRequestIdRef.current = null
-      setIsRunning(false)
-      setError(formatError(runError))
-    }
-  }, [buildCurrentDocumentSyncFile, getLatestProjectFiles, isReady])
+  const { currentDocumentId } = useEditorOpenDocContext()
+  const { getPythonRunner } = usePythonExecutionContext()
+  const pythonRunner = useMemo(
+    () => (currentDocumentId ? getPythonRunner(currentDocumentId) : null),
+    [currentDocumentId, getPythonRunner]
+  )
 
-  const handleStop = useCallback(() => {
-    const client = clientRef.current
-    if (!client) {
-      return
-    }
+  const { output, error, status } = useSyncExternalStore(
+    pythonRunner ? pythonRunner.subscribe : emptySubscribe,
+    pythonRunner ? pythonRunner.getState : getDefaultState
+  )
 
-    currentRequestIdRef.current = null
-    client.stop()
-    setIsRunning(false)
-    setIsReady(false)
-    setIsLoadingPyodide(true)
-    appendOutput(t('execution_stopped'))
-  }, [appendOutput, t])
+  if (!pythonRunner) {
+    return null
+  }
 
   return (
     <div className="ide-redesign-python-output-pane">
@@ -176,17 +34,25 @@ export default function PythonOutputPane() {
         <div className="toolbar-pdf-left">
           <div className="compile-button-group">
             <OLButton
-              onClick={isRunning ? handleStop : handleRun}
-              variant={isRunning ? 'danger' : 'primary'}
+              onClick={() => {
+                if (status === 'running') {
+                  pythonRunner.interrupt()
+                } else {
+                  pythonRunner.run()
+                }
+              }}
+              variant={status === 'running' ? 'danger' : 'primary'}
               className="compile-button align-items-center py-0 px-3"
-              disabled={!isRunning && !isReady}
+              disabled={status === 'loading'}
               aria-label={
-                isRunning ? t('stop_python_execution') : t('run_python_code')
+                status === 'running'
+                  ? t('stop_python_execution')
+                  : t('run_python_code')
               }
             >
-              {isRunning ? t('stop') : t('run')}
+              {status === 'running' ? t('stop') : t('run')}
               <MaterialIcon
-                type={isRunning ? 'stop' : 'play_arrow'}
+                type={status === 'running' ? 'stop' : 'play_arrow'}
                 className="ml-2"
               />
             </OLButton>
@@ -195,12 +61,12 @@ export default function PythonOutputPane() {
       </OLButtonToolbar>
 
       <div className="ide-redesign-python-output-pane-body">
-        {isLoadingPyodide && (
+        {status === 'loading' && (
           <div className="ide-redesign-python-output-pane-placeholder">
             {t('loading_python_runtime')}
           </div>
         )}
-        {!isLoadingPyodide && !error && output.length === 0 && (
+        {status !== 'loading' && !error && output.length === 0 && (
           <div className="ide-redesign-python-output-pane-placeholder">
             {t('run_current_script_to_see_output')}
           </div>
@@ -217,11 +83,3 @@ export default function PythonOutputPane() {
     </div>
   )
 }
-
-function formatError(error: unknown): string {
-  if (error instanceof Error) {
-    return error.message
-  }
-
-  return String(error)
-}

+ 213 - 0
services/web/frontend/js/features/ide-react/components/editor/python/python-runner.ts

@@ -0,0 +1,213 @@
+// Per-file Python execution manager. Each PythonRunner owns a PyodideWorkerClient
+// and exposes a subscribe/getState API for use with useSyncExternalStore,
+// so React components can reactively read execution status and output.
+import { v4 as uuid } from 'uuid'
+import { debugConsole } from '@/utils/debugging'
+import { PyodideWorkerClient } from './pyodide-worker-client'
+
+const MAX_OUTPUT_LINES = 100
+
+export type ExecutionStatus =
+  | 'loading'
+  | 'idle'
+  | 'running'
+  | 'finished'
+  | 'errored'
+
+export type ExecutionContext = {
+  code: string
+  files: { relativePath: string; content: string }[]
+}
+
+type Listener = () => void
+
+export type PythonRunnerState = {
+  output: string[]
+  status: ExecutionStatus
+  error: string | null
+}
+
+export const DEFAULT_STATE: PythonRunnerState = {
+  output: [],
+  status: 'loading',
+  error: null,
+}
+
+export class PythonRunner {
+  readonly fileId: string
+  private client: PyodideWorkerClient | null = null
+  private readonly baseAssetPath: string
+  private readonly createWorker: () => Worker
+  private readonly getExecutionContext: () => Promise<ExecutionContext | null>
+  private listeners = new Set<Listener>()
+
+  private activeExecutionId: string | null = null
+  private state: PythonRunnerState = DEFAULT_STATE
+
+  constructor(
+    fileId: string,
+    baseAssetPath: string,
+    getExecutionContext: () => Promise<ExecutionContext | null>,
+    createWorker: () => Worker
+  ) {
+    this.fileId = fileId
+    this.baseAssetPath = baseAssetPath
+    this.createWorker = createWorker
+    this.getExecutionContext = getExecutionContext
+  }
+
+  subscribe = (listener: Listener): (() => void) => {
+    this.listeners.add(listener)
+    return () => this.listeners.delete(listener)
+  }
+
+  getState = () => this.state
+
+  private updateState(fields: Partial<PythonRunnerState>) {
+    const prev = this.state
+    const output = fields.output ?? prev.output
+    const status = fields.status ?? prev.status
+    const error = fields.error !== undefined ? fields.error : prev.error
+
+    if (
+      output === prev.output &&
+      status === prev.status &&
+      error === prev.error
+    ) {
+      return
+    }
+
+    this.state = { output, status, error }
+
+    for (const listener of this.listeners) {
+      listener()
+    }
+  }
+
+  init() {
+    if (this.client) {
+      return
+    }
+
+    this.updateState({ status: 'loading', error: null })
+
+    this.client = new PyodideWorkerClient({
+      baseAssetPath: this.baseAssetPath,
+      createWorker: this.createWorker,
+      onLifecycle: event => {
+        switch (event.type) {
+          case 'loaded':
+            this.updateState({ status: 'idle', error: null })
+            return
+
+          case 'loading-failed':
+            debugConsole.error('Failed to load Python runtime', event.error)
+            this.updateState({ status: 'errored', error: event.error })
+            return
+
+          case 'run-finished':
+            if (
+              event.fileId !== this.fileId ||
+              this.activeExecutionId !== event.executionId
+            ) {
+              return
+            }
+            this.activeExecutionId = null
+            this.updateState({ status: 'finished' })
+        }
+      },
+      onOutput: (_stream, line, fileId, executionId) => {
+        if (fileId !== this.fileId || this.activeExecutionId !== executionId) {
+          return
+        }
+        this.updateState({ output: appendCapped(this.state.output, line) })
+      },
+    })
+  }
+
+  async run() {
+    if (!this.client || this.state.status === 'loading') {
+      return
+    }
+
+    let context: ExecutionContext | null
+    try {
+      context = await this.getExecutionContext()
+    } catch (err) {
+      debugConsole.error('Failed to build execution context', err)
+      this.updateState({ status: 'errored', error: formatError(err) })
+      return
+    }
+
+    // Re-check after await — status may have changed but TypeScript
+    // still narrows from the pre-await check, so we cast back.
+    if (
+      !context ||
+      !this.client ||
+      (this.state.status as ExecutionStatus) === 'loading'
+    ) {
+      return
+    }
+
+    const { code, files } = context
+
+    const executionId = uuid()
+    this.activeExecutionId = executionId
+    this.updateState({ status: 'running', output: [], error: null })
+
+    try {
+      this.client.runCode(code, {
+        fileId: this.fileId,
+        executionId,
+        files,
+      })
+    } catch (runError) {
+      if (this.activeExecutionId !== executionId) {
+        return
+      }
+      this.activeExecutionId = null
+      this.updateState({ status: 'errored', error: formatError(runError) })
+    }
+  }
+
+  interrupt() {
+    if (!this.client) {
+      return
+    }
+
+    this.client.reset()
+    this.activeExecutionId = null
+
+    // The worker is terminated and recreated by reset(), so it needs to
+    // reload Pyodide. The 'loaded' lifecycle callback will transition
+    // back to 'idle'.
+    this.updateState({
+      status: 'loading',
+      output:
+        this.state.status === 'running'
+          ? appendCapped(this.state.output, 'Execution interrupted')
+          : this.state.output,
+    })
+  }
+
+  destroy() {
+    if (this.client) {
+      this.client.destroy()
+      this.client = null
+    }
+  }
+}
+
+function appendCapped(existing: string[], line: string): string[] {
+  const updated = [...existing, line]
+  return updated.length > MAX_OUTPUT_LINES
+    ? updated.slice(-MAX_OUTPUT_LINES)
+    : updated
+}
+
+function formatError(error: unknown): string {
+  if (error instanceof Error) {
+    return error.message
+  }
+  return String(error)
+}

+ 20 - 17
services/web/frontend/js/features/ide-react/components/layout/python-editor-split.tsx

@@ -2,26 +2,29 @@ import { Panel, PanelGroup } from 'react-resizable-panels'
 import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
 import PythonOutputPane from '@/features/ide-react/components/editor/python/python-output-pane'
 import SourceEditor from '@/features/source-editor/components/source-editor'
+import { PythonExecutionProvider } from '@/features/ide-react/context/python-execution-context'
 
 export const PythonEditorSplit = () => {
   return (
-    <PanelGroup
-      autoSaveId="ide-redesign-editor-python-output"
-      direction="vertical"
-      className="ide-redesign-python-editor-split"
-    >
-      <Panel id="ide-redesign-panel-source-editor-content" order={1}>
-        <SourceEditor />
-      </Panel>
-      <VerticalResizeHandle id="ide-redesign-editor-python-output" />
-      <Panel
-        id="ide-redesign-panel-python-output"
-        order={2}
-        defaultSize={35}
-        minSize={10}
+    <PythonExecutionProvider>
+      <PanelGroup
+        autoSaveId="ide-redesign-editor-python-output"
+        direction="vertical"
+        className="ide-redesign-python-editor-split"
       >
-        <PythonOutputPane />
-      </Panel>
-    </PanelGroup>
+        <Panel id="ide-redesign-panel-source-editor-content" order={1}>
+          <SourceEditor />
+        </Panel>
+        <VerticalResizeHandle id="ide-redesign-editor-python-output" />
+        <Panel
+          id="ide-redesign-panel-python-output"
+          order={2}
+          defaultSize={35}
+          minSize={10}
+        >
+          <PythonOutputPane />
+        </Panel>
+      </PanelGroup>
+    </PythonExecutionProvider>
   )
 }

+ 140 - 0
services/web/frontend/js/features/ide-react/context/python-execution-context.tsx

@@ -0,0 +1,140 @@
+import {
+  createContext,
+  FC,
+  PropsWithChildren,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+} from 'react'
+import getMeta from '@/utils/meta'
+import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
+import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useProjectContext } from '@/shared/context/project-context'
+import {
+  PythonRunner,
+  ExecutionContext,
+} from '@/features/ide-react/components/editor/python/python-runner'
+
+// Worker factory lives here (a .tsx file) so that the full
+// `new Worker(new URL(..., import.meta.url))` expression is in a single place
+// where webpack 5 can statically detect it and create a proper worker bundle.
+// Keeping import.meta.url out of .ts files also avoids Node.js 24 switching to
+// ESM mode and breaking CJS-based test loading via @babel/register.
+const createPyodideWorker = () =>
+  new Worker(
+    /* webpackChunkName: "pyodide-worker" */
+    new URL('../components/editor/python/pyodide.worker.ts', import.meta.url),
+    { type: 'module' }
+  )
+
+export interface PythonExecutionContextValue {
+  getPythonRunner: (fileId: string) => PythonRunner
+}
+
+export const PythonExecutionContext = createContext<
+  PythonExecutionContextValue | undefined
+>(undefined)
+
+export const PythonExecutionProvider: FC<PropsWithChildren> = ({
+  children,
+}) => {
+  const { openDocs } = useEditorManagerContext()
+  const { projectSnapshot } = useProjectContext()
+  const { pathInFolder } = useFileTreePathContext()
+  const runnersRef = useRef(new Map<string, PythonRunner>())
+  const baseAssetPathRef = useRef<string | null>(null)
+
+  const pathInFolderRef = useRef(pathInFolder)
+  pathInFolderRef.current = pathInFolder
+
+  // Refreshes the project snapshot and resolves the source code and all project
+  // files for the given fileId, to be passed to the executor for running.
+  const getExecutionContext = useCallback(
+    async (fileId: string): Promise<ExecutionContext | null> => {
+      await openDocs.awaitBufferedOps(AbortSignal.timeout(5000))
+      await projectSnapshot.refresh()
+
+      const relativePath = pathInFolderRef.current(fileId)
+      if (!relativePath) {
+        return null
+      }
+
+      const code = projectSnapshot.getDocContents(relativePath)
+      if (code == null) {
+        return null
+      }
+
+      const docPaths = projectSnapshot.getDocPaths()
+      const files = docPaths
+        .map(docPath => {
+          const content = projectSnapshot.getDocContents(docPath)
+          return content != null ? { relativePath: docPath, content } : null
+        })
+        .filter(
+          (f): f is { relativePath: string; content: string } => f != null
+        )
+
+      return { code, files }
+    },
+    [openDocs, projectSnapshot]
+  )
+
+  const getPythonRunner = useCallback(
+    (fileId: string): PythonRunner => {
+      const existing = runnersRef.current.get(fileId)
+      if (existing) {
+        return existing
+      }
+
+      if (!baseAssetPathRef.current) {
+        baseAssetPathRef.current = new URL(
+          getMeta('ol-baseAssetPath'),
+          window.location.href
+        ).toString()
+      }
+
+      const runner = new PythonRunner(
+        fileId,
+        baseAssetPathRef.current,
+        () => getExecutionContext(fileId),
+        createPyodideWorker
+      )
+      runner.init()
+      runnersRef.current.set(fileId, runner)
+      return runner
+    },
+    [getExecutionContext]
+  )
+
+  useEffect(() => {
+    const runners = runnersRef.current
+    return () => {
+      for (const runner of runners.values()) {
+        runner.destroy()
+      }
+      runners.clear()
+    }
+  }, [])
+
+  const value = useMemo(() => ({ getPythonRunner }), [getPythonRunner])
+
+  return (
+    <PythonExecutionContext.Provider value={value}>
+      {children}
+    </PythonExecutionContext.Provider>
+  )
+}
+
+export const usePythonExecutionContext = (): PythonExecutionContextValue => {
+  const context = useContext(PythonExecutionContext)
+
+  if (!context) {
+    throw new Error(
+      'usePythonExecutionContext is only available inside PythonExecutionContext.Provider'
+    )
+  }
+
+  return context
+}

+ 2 - 3
services/web/locales/en.json

@@ -808,7 +808,6 @@
   "examples_lowercase": "examples",
   "examples_to_help_you_learn": "Examples to help you learn how to use powerful LaTeX packages and techniques.",
   "exclusive_access_with_labs": "Exclusive access to early-stage experiments",
-  "execution_stopped": "Execution stopped.",
   "existing_plan_active_until_term_end": "Your existing plan and its features will remain active until the end of the current billing period.",
   "expand": "Expand",
   "experiment_full_check_back_soon": "Sorry, this experiment is full. Spaces may become available, so check back soon.",
@@ -2127,7 +2126,7 @@
   "ru": "Russian",
   "run": "Run",
   "run_current_script_to_see_output": "Run the current script to see output.",
-  "run_python_code": "Run Python Code",
+  "run_python_code": "Run Python code",
   "saml": "SAML",
   "saml_auth_error": "Sorry, your identity provider responded with an error.  Please contact your administrator for more information.",
   "saml_authentication_required_error": "Other login methods have been disabled by your group administrator. Please use your group SSO login.",
@@ -2417,7 +2416,7 @@
   "stop_on_first_error_enabled_description": "<0>“Stop on first error” is enabled.</0> Disabling it may allow the compiler to produce a PDF (but your project will still have errors).",
   "stop_on_first_error_enabled_title": "No PDF: Stop on first error enabled",
   "stop_on_validation_error": "Check syntax before compile",
-  "stop_python_execution": "Stop Python Execution",
+  "stop_python_execution": "Stop Python execution",
   "stops_compiling_after_the_first_error_so_you_can_fix_issues_one_at_a_time": "Stops compiling after the first error so you can fix issues one at a time",
   "store_your_work": "Store your work on your own infrastructure",
   "stretch_width_to_text": "Stretch width to text",

+ 13 - 6
services/web/test/frontend/features/ide-react/components/python-output-pane.spec.tsx

@@ -7,6 +7,7 @@ import {
 import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 import { ProjectContext } from '@/shared/context/project-context'
 import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
+import { PythonExecutionProvider } from '@/features/ide-react/context/python-execution-context'
 
 const pythonExecutableScript: Record<string, string> = {
   file_id: 'test-py-doc-id',
@@ -83,11 +84,13 @@ describe('<PythonOutputPane />', function () {
         }}
         providers={{ FileTreePathProvider, ProjectProvider }}
       >
-        <PythonOutputPane />
+        <PythonExecutionProvider>
+          <PythonOutputPane />
+        </PythonExecutionProvider>
       </EditorProviders>
     )
 
-    cy.findByRole('button', { name: 'Run Python Code' })
+    cy.findByRole('button', { name: 'Run Python code' })
       .should('not.be.disabled')
       .click()
     cy.findByText('hello!').should('exist')
@@ -122,11 +125,13 @@ describe('<PythonOutputPane />', function () {
         }}
         providers={{ FileTreePathProvider, ProjectProvider }}
       >
-        <PythonOutputPane />
+        <PythonExecutionProvider>
+          <PythonOutputPane />
+        </PythonExecutionProvider>
       </EditorProviders>
     )
 
-    cy.findByRole('button', { name: 'Run Python Code' })
+    cy.findByRole('button', { name: 'Run Python code' })
       .should('not.be.disabled')
       .click()
     cy.findByText('hello!').should('exist')
@@ -195,11 +200,13 @@ describe('<PythonOutputPane />', function () {
           ProjectProvider,
         }}
       >
-        <PythonOutputPane />
+        <PythonExecutionProvider>
+          <PythonOutputPane />
+        </PythonExecutionProvider>
       </EditorProviders>
     )
 
-    cy.findByRole('button', { name: 'Run Python Code' })
+    cy.findByRole('button', { name: 'Run Python code' })
       .should('not.be.disabled')
       .click()
     cy.findByText('name,type').should('exist')

+ 94 - 84
services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts

@@ -1,64 +1,24 @@
 import { expect } from 'chai'
 import { PyodideWorkerClient } from '@/features/ide-react/components/editor/python/pyodide-worker-client'
-
-type WorkerMessageListener = (event: MessageEvent) => void
+import { WorkerMock, createWorker } from './worker-mock'
 
 const BASE_ASSET_PATH = 'https://assets.example.test/'
 
-class WorkerMock {
-  static instances: WorkerMock[] = []
-
-  readonly postedMessages: any[] = []
-  terminated = false
-  private messageListeners: WorkerMessageListener[] = []
-
-  constructor() {
-    WorkerMock.instances.push(this)
-  }
-
-  addEventListener(type: string, listener: WorkerMessageListener) {
-    if (type === 'message') {
-      this.messageListeners.push(listener)
-    }
-  }
-
-  postMessage(message: unknown) {
-    this.postedMessages.push(message)
-  }
-
-  terminate() {
-    this.terminated = true
-  }
-
-  emitMessage(message: unknown) {
-    for (const listener of this.messageListeners) {
-      listener({ data: message, target: this } as unknown as MessageEvent)
-    }
-  }
-}
-
 describe('PyodideWorkerClient', function () {
-  let originalWorker: typeof Worker | undefined
-
   beforeEach(function () {
-    originalWorker = window.Worker
-    // @ts-ignore - allow mocking Worker
-    window.Worker = WorkerMock
     WorkerMock.instances.length = 0
   })
 
-  afterEach(function () {
-    if (originalWorker) {
-      window.Worker = originalWorker
-    }
-  })
-
   it('queues runCode until the worker reports listening', function () {
-    const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
+    const client = new PyodideWorkerClient({
+      baseAssetPath: BASE_ASSET_PATH,
+      createWorker,
+    })
     const worker = WorkerMock.instances[0]
 
     client.runCode('print("ok")', {
-      requestId: 'main.py',
+      fileId: 'main.py',
+      executionId: 'exec-1',
       files: [{ relativePath: 'main.py', content: 'print("ok")' }],
     })
     expect(worker.postedMessages).to.have.length(0)
@@ -74,7 +34,8 @@ describe('PyodideWorkerClient', function () {
     )
     expect(runRequest).to.include({
       type: 'run-code',
-      id: 'main.py',
+      fileId: 'main.py',
+      executionId: 'exec-1',
       code: 'print("ok")',
     })
     expect(runRequest.files).to.deep.equal([
@@ -83,29 +44,44 @@ describe('PyodideWorkerClient', function () {
   })
 
   it('sends runCode as fire-and-forget', function () {
-    const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
+    const client = new PyodideWorkerClient({
+      baseAssetPath: BASE_ASSET_PATH,
+      createWorker,
+    })
     const worker = WorkerMock.instances[0]
     worker.emitMessage({ type: 'listening' })
 
     client.runCode('raise RuntimeError("boom")', {
-      requestId: 'boom.py',
+      fileId: 'boom.py',
+      executionId: 'exec-2',
       files: [],
     })
     const runRequest = worker.postedMessages.find(
       message => message.type === 'run-code'
     )
-    expect(runRequest).to.include({ type: 'run-code', id: 'boom.py' })
+    expect(runRequest).to.include({
+      type: 'run-code',
+      fileId: 'boom.py',
+      executionId: 'exec-2',
+    })
   })
 
   function setupClientWithLifecycleTracking() {
-    const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
-    const worker = WorkerMock.instances[0]
-    const lifecycleEvents: Array<{
+    const lifecycleEvents: {
       type: string
-      requestId?: string
+      fileId?: string
+      executionId?: string
       outputs?: string[]
-    }> = []
-    client.setLifecycleCallback(event => lifecycleEvents.push(event))
+    }[] = []
+
+    const client = new PyodideWorkerClient({
+      baseAssetPath: BASE_ASSET_PATH,
+      createWorker,
+      onLifecycle: event => {
+        lifecycleEvents.push(event)
+      },
+    })
+    const worker = WorkerMock.instances[0]
     worker.emitMessage({ type: 'listening' })
     return { client, worker, lifecycleEvents }
   }
@@ -114,16 +90,22 @@ describe('PyodideWorkerClient', function () {
     const { client, worker, lifecycleEvents } =
       setupClientWithLifecycleTracking()
 
-    client.runCode('print("ok")', { requestId: 'main.py', files: [] })
+    client.runCode('print("ok")', {
+      fileId: 'main.py',
+      executionId: 'exec-3',
+      files: [],
+    })
     worker.emitMessage({
       type: 'run-code-result',
-      id: 'main.py',
+      fileId: 'main.py',
+      executionId: 'exec-3',
       outputs: ['/project/output.txt'],
     })
 
     expect(lifecycleEvents).to.deep.include({
       type: 'run-finished',
-      requestId: 'main.py',
+      fileId: 'main.py',
+      executionId: 'exec-3',
       outputs: ['/project/output.txt'],
     })
   })
@@ -132,16 +114,22 @@ describe('PyodideWorkerClient', function () {
     const { client, worker, lifecycleEvents } =
       setupClientWithLifecycleTracking()
 
-    client.runCode('write_files()', { requestId: 'main.py', files: [] })
+    client.runCode('write_files()', {
+      fileId: 'main.py',
+      executionId: 'exec-4',
+      files: [],
+    })
     worker.emitMessage({
       type: 'run-code-result',
-      id: 'main.py',
+      fileId: 'main.py',
+      executionId: 'exec-4',
       outputs: ['/project/fig1.png', '/project/results/data.csv'],
     })
 
     expect(lifecycleEvents).to.deep.include({
       type: 'run-finished',
-      requestId: 'main.py',
+      fileId: 'main.py',
+      executionId: 'exec-4',
       outputs: ['/project/fig1.png', '/project/results/data.csv'],
     })
   })
@@ -150,28 +138,37 @@ describe('PyodideWorkerClient', function () {
     const { client, worker, lifecycleEvents } =
       setupClientWithLifecycleTracking()
 
-    client.runCode('print("no writes")', { requestId: 'main.py', files: [] })
+    client.runCode('print("no writes")', {
+      fileId: 'main.py',
+      executionId: 'exec-5',
+      files: [],
+    })
     worker.emitMessage({
       type: 'run-code-result',
-      id: 'main.py',
+      fileId: 'main.py',
+      executionId: 'exec-5',
       outputs: [],
     })
 
     expect(lifecycleEvents).to.deep.include({
       type: 'run-finished',
-      requestId: 'main.py',
+      fileId: 'main.py',
+      executionId: 'exec-5',
       outputs: [],
     })
   })
 
   it('reports lifecycle failure and rejects future run requests when loading fails', function () {
-    const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
-    const worker = WorkerMock.instances[0]
     const lifecycleEvents: Array<{ type: string; error?: string }> = []
 
-    client.setLifecycleCallback(event => {
-      lifecycleEvents.push(event)
+    const client = new PyodideWorkerClient({
+      baseAssetPath: BASE_ASSET_PATH,
+      createWorker,
+      onLifecycle: event => {
+        lifecycleEvents.push(event)
+      },
     })
+    const worker = WorkerMock.instances[0]
 
     worker.emitMessage({
       type: 'loading-failed',
@@ -182,12 +179,19 @@ describe('PyodideWorkerClient', function () {
       { type: 'loading-failed', error: 'runtime unavailable' },
     ])
     expect(() =>
-      client.runCode('print("ok")', { requestId: 'main.py', files: [] })
+      client.runCode('print("ok")', {
+        fileId: 'main.py',
+        executionId: 'exec-4',
+        files: [],
+      })
     ).to.throw('runtime unavailable')
   })
 
   it('terminates the worker even when destroy is called after loading failure', function () {
-    const client = new PyodideWorkerClient({ baseAssetPath: BASE_ASSET_PATH })
+    const client = new PyodideWorkerClient({
+      baseAssetPath: BASE_ASSET_PATH,
+      createWorker,
+    })
     const worker = WorkerMock.instances[0]
 
     worker.emitMessage({
@@ -199,30 +203,32 @@ describe('PyodideWorkerClient', function () {
     expect(worker.terminated).to.equal(true)
   })
 
-  describe('stop', function () {
+  describe('reset', function () {
     it('terminates the current worker and creates a new one', function () {
       const client = new PyodideWorkerClient({
         baseAssetPath: BASE_ASSET_PATH,
+        createWorker,
       })
       const originalWorker = WorkerMock.instances[0]
       originalWorker.emitMessage({ type: 'listening' })
       originalWorker.emitMessage({ type: 'loaded' })
 
-      client.stop()
+      client.reset()
 
       expect(originalWorker.terminated).to.equal(true)
       expect(WorkerMock.instances).to.have.length(2)
     })
 
-    it('sends init to the new worker once it reports listening', function () {
+    it('sends init to the new worker once it reports listening after reset', function () {
       const client = new PyodideWorkerClient({
         baseAssetPath: BASE_ASSET_PATH,
+        createWorker,
       })
       const originalWorker = WorkerMock.instances[0]
       originalWorker.emitMessage({ type: 'listening' })
       originalWorker.emitMessage({ type: 'loaded' })
 
-      client.stop()
+      client.reset()
 
       const newWorker = WorkerMock.instances[1]
       expect(newWorker.postedMessages).to.have.length(0)
@@ -233,22 +239,24 @@ describe('PyodideWorkerClient', function () {
       ])
     })
 
-    it('allows running code on the new worker after stop', function () {
+    it('allows running code on the new worker after reset', function () {
       const client = new PyodideWorkerClient({
         baseAssetPath: BASE_ASSET_PATH,
+        createWorker,
       })
       const originalWorker = WorkerMock.instances[0]
       originalWorker.emitMessage({ type: 'listening' })
       originalWorker.emitMessage({ type: 'loaded' })
 
-      client.stop()
+      client.reset()
 
       const newWorker = WorkerMock.instances[1]
       newWorker.emitMessage({ type: 'listening' })
       newWorker.emitMessage({ type: 'loaded' })
 
-      client.runCode('print("after stop")', {
-        requestId: 'main.py',
+      client.runCode('print("after reset")', {
+        fileId: 'main.py',
+        executionId: 'exec-5',
         files: [],
       })
 
@@ -257,21 +265,23 @@ describe('PyodideWorkerClient', function () {
       )
       expect(runRequest).to.include({
         type: 'run-code',
-        id: 'main.py',
-        code: 'print("after stop")',
+        fileId: 'main.py',
+        executionId: 'exec-5',
+        code: 'print("after reset")',
       })
     })
 
-    it('is a no-op after destroy', function () {
+    it('reset is a no-op after destroy', function () {
       const client = new PyodideWorkerClient({
         baseAssetPath: BASE_ASSET_PATH,
+        createWorker,
       })
       const originalWorker = WorkerMock.instances[0]
       originalWorker.emitMessage({ type: 'listening' })
       originalWorker.emitMessage({ type: 'loaded' })
 
       client.destroy()
-      client.stop()
+      client.reset()
 
       // No new worker should have been created
       expect(WorkerMock.instances).to.have.length(1)

+ 332 - 0
services/web/test/frontend/features/ide-react/unit/editor/python-runner.spec.ts

@@ -0,0 +1,332 @@
+import { expect } from 'chai'
+import sinon from 'sinon'
+import {
+  PythonRunner,
+  DEFAULT_STATE,
+  ExecutionContext,
+} from '@/features/ide-react/components/editor/python/python-runner'
+import { WorkerMock, createWorker } from './worker-mock'
+
+const BASE_ASSET_PATH = 'https://assets.example.test/'
+const FILE_ID = 'file-1'
+
+function createRunner(
+  overrides: {
+    fileId?: string
+    getExecutionContext?: () => Promise<ExecutionContext | null>
+  } = {}
+) {
+  const fileId = overrides.fileId ?? FILE_ID
+  const getExecutionContext =
+    overrides.getExecutionContext ??
+    (() =>
+      Promise.resolve({
+        code: 'print("hello")',
+        files: [{ relativePath: 'main.py', content: 'print("hello")' }],
+      }))
+
+  const runner = new PythonRunner(
+    fileId,
+    BASE_ASSET_PATH,
+    getExecutionContext,
+    createWorker
+  )
+  return runner
+}
+
+function initAndLoad(runner: PythonRunner) {
+  runner.init()
+  const worker = WorkerMock.instances[WorkerMock.instances.length - 1]
+  worker.emitMessage({ type: 'listening' })
+  worker.emitMessage({ type: 'loaded' })
+  return worker
+}
+
+describe('PythonRunner', function () {
+  beforeEach(function () {
+    WorkerMock.instances.length = 0
+  })
+
+  describe('initial state', function () {
+    it('starts with default snapshot before init', function () {
+      const runner = createRunner()
+      expect(runner.getState()).to.deep.equal(DEFAULT_STATE)
+    })
+  })
+
+  describe('init and lifecycle', function () {
+    it('transitions to loading on init', function () {
+      const runner = createRunner()
+      runner.init()
+      expect(runner.getState().status).to.equal('loading')
+    })
+
+    it('transitions to idle when worker reports loaded', function () {
+      const runner = createRunner()
+      initAndLoad(runner)
+      expect(runner.getState().status).to.equal('idle')
+    })
+
+    it('transitions to errored on loading failure', function () {
+      const runner = createRunner()
+      runner.init()
+      const worker = WorkerMock.instances[0]
+      worker.emitMessage({ type: 'listening' })
+      worker.emitMessage({
+        type: 'loading-failed',
+        error: 'network error',
+      })
+      expect(runner.getState().status).to.equal('errored')
+      expect(runner.getState().error).to.equal('network error')
+    })
+
+    it('clears error on successful load after failure', function () {
+      const runner = createRunner()
+      runner.init()
+      const worker = WorkerMock.instances[0]
+      worker.emitMessage({ type: 'listening' })
+      worker.emitMessage({ type: 'loaded' })
+      expect(runner.getState().error).to.equal(null)
+    })
+
+    it('is a no-op if already initialized', function () {
+      const runner = createRunner()
+      runner.init()
+      runner.init()
+      expect(WorkerMock.instances).to.have.length(1)
+    })
+  })
+
+  describe('run', function () {
+    it('transitions to running then finished', async function () {
+      const runner = createRunner()
+      const worker = initAndLoad(runner)
+
+      await runner.run()
+      expect(runner.getState().status).to.equal('running')
+
+      const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
+      worker.emitMessage({
+        type: 'run-code-result',
+        fileId: FILE_ID,
+        executionId: runMsg.executionId,
+        outputs: [],
+      })
+
+      expect(runner.getState().status).to.equal('finished')
+    })
+
+    it('clears previous output on new run', async function () {
+      const runner = createRunner()
+      const worker = initAndLoad(runner)
+
+      await runner.run()
+      const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
+      worker.emitMessage({
+        type: 'output-line',
+        stream: 'stdout',
+        line: 'first run output',
+        fileId: FILE_ID,
+        executionId: runMsg.executionId,
+      })
+      worker.emitMessage({
+        type: 'run-code-result',
+        fileId: FILE_ID,
+        executionId: runMsg.executionId,
+        outputs: [],
+      })
+      expect(runner.getState().output).to.deep.equal(['first run output'])
+
+      await runner.run()
+      expect(runner.getState().output).to.deep.equal([])
+    })
+
+    it('is a no-op while still loading', async function () {
+      const runner = createRunner()
+      runner.init()
+      await runner.run()
+      expect(runner.getState().status).to.equal('loading')
+    })
+
+    it('is a no-op when getExecutionContext returns null', async function () {
+      const runner = createRunner({
+        getExecutionContext: () => Promise.resolve(null),
+      })
+      initAndLoad(runner)
+
+      await runner.run()
+      expect(runner.getState().status).to.equal('idle')
+    })
+
+    it('transitions to errored when getExecutionContext rejects', async function () {
+      const runner = createRunner({
+        getExecutionContext: () => Promise.reject(new Error('network failure')),
+      })
+      initAndLoad(runner)
+
+      await runner.run()
+      expect(runner.getState().status).to.equal('errored')
+      expect(runner.getState().error).to.equal('network failure')
+    })
+  })
+
+  describe('output', function () {
+    it('accumulates output lines for the matching file', async function () {
+      const runner = createRunner()
+      const worker = initAndLoad(runner)
+
+      await runner.run()
+      const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
+
+      worker.emitMessage({
+        type: 'output-line',
+        stream: 'stdout',
+        line: 'line 1',
+        fileId: FILE_ID,
+        executionId: runMsg.executionId,
+      })
+      worker.emitMessage({
+        type: 'output-line',
+        stream: 'stderr',
+        line: 'line 2',
+        fileId: FILE_ID,
+        executionId: runMsg.executionId,
+      })
+
+      expect(runner.getState().output).to.deep.equal(['line 1', 'line 2'])
+    })
+
+    it('ignores output for a different fileId', async function () {
+      const runner = createRunner()
+      const worker = initAndLoad(runner)
+
+      await runner.run()
+      const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
+
+      worker.emitMessage({
+        type: 'output-line',
+        stream: 'stdout',
+        line: 'other file output',
+        fileId: 'different-file',
+        executionId: runMsg.executionId,
+      })
+
+      expect(runner.getState().output).to.deep.equal([])
+    })
+
+    it('ignores output for a stale executionId', async function () {
+      const runner = createRunner()
+      const worker = initAndLoad(runner)
+
+      await runner.run()
+
+      worker.emitMessage({
+        type: 'output-line',
+        stream: 'stdout',
+        line: 'stale output',
+        fileId: FILE_ID,
+        executionId: 'old-execution-id',
+      })
+
+      expect(runner.getState().output).to.deep.equal([])
+    })
+
+    it('caps output at 100 lines', async function () {
+      const runner = createRunner()
+      const worker = initAndLoad(runner)
+
+      await runner.run()
+      const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
+
+      for (let i = 0; i < 110; i++) {
+        worker.emitMessage({
+          type: 'output-line',
+          stream: 'stdout',
+          line: `line ${i}`,
+          fileId: FILE_ID,
+          executionId: runMsg.executionId,
+        })
+      }
+
+      const output = runner.getState().output
+      expect(output).to.have.length(100)
+      expect(output[0]).to.equal('line 10')
+      expect(output[99]).to.equal('line 109')
+    })
+  })
+
+  describe('interrupt', function () {
+    it('appends interrupted message and transitions to loading when running', async function () {
+      const runner = createRunner()
+      const worker = initAndLoad(runner)
+
+      await runner.run()
+      const runMsg = worker.postedMessages.find(m => m.type === 'run-code')
+      worker.emitMessage({
+        type: 'output-line',
+        stream: 'stdout',
+        line: 'partial output',
+        fileId: FILE_ID,
+        executionId: runMsg.executionId,
+      })
+
+      runner.interrupt()
+
+      expect(runner.getState().status).to.equal('loading')
+      expect(runner.getState().output).to.deep.equal([
+        'partial output',
+        'Execution interrupted',
+      ])
+    })
+
+    it('does not append interrupted message when not running', function () {
+      const runner = createRunner()
+      initAndLoad(runner)
+
+      runner.interrupt()
+
+      expect(runner.getState().status).to.equal('loading')
+      expect(runner.getState().output).to.deep.equal([])
+    })
+  })
+
+  describe('subscribe', function () {
+    it('notifies listeners on state changes', function () {
+      const runner = createRunner()
+      const listener = sinon.stub()
+
+      runner.subscribe(listener)
+      initAndLoad(runner)
+
+      expect(listener.callCount).to.be.greaterThan(0)
+    })
+
+    it('stops notifying after unsubscribe', function () {
+      const runner = createRunner()
+      const listener = sinon.stub()
+
+      const unsubscribe = runner.subscribe(listener)
+      runner.init()
+      const countAfterInit = listener.callCount
+
+      unsubscribe()
+      const worker = WorkerMock.instances[0]
+      worker.emitMessage({ type: 'listening' })
+      worker.emitMessage({ type: 'loaded' })
+
+      expect(listener.callCount).to.equal(countAfterInit)
+    })
+  })
+
+  describe('destroy', function () {
+    it('terminates the worker', function () {
+      const runner = createRunner()
+      initAndLoad(runner)
+
+      runner.destroy()
+
+      const worker = WorkerMock.instances[0]
+      expect(worker.terminated).to.equal(true)
+    })
+  })
+})

+ 35 - 0
services/web/test/frontend/features/ide-react/unit/editor/worker-mock.ts

@@ -0,0 +1,35 @@
+type WorkerMessageListener = (event: MessageEvent) => void
+
+export class WorkerMock {
+  static instances: WorkerMock[] = []
+
+  readonly postedMessages: any[] = []
+  terminated = false
+  private messageListeners: WorkerMessageListener[] = []
+
+  constructor() {
+    WorkerMock.instances.push(this)
+  }
+
+  addEventListener(type: string, listener: WorkerMessageListener) {
+    if (type === 'message') {
+      this.messageListeners.push(listener)
+    }
+  }
+
+  postMessage(message: unknown) {
+    this.postedMessages.push(message)
+  }
+
+  terminate() {
+    this.terminated = true
+  }
+
+  emitMessage(message: unknown) {
+    for (const listener of this.messageListeners) {
+      listener({ data: message, target: this } as unknown as MessageEvent)
+    }
+  }
+}
+
+export const createWorker = () => new WorkerMock() as unknown as Worker