ソースを参照

[web] Add stop button for Python code execution via worker termination

GitOrigin-RevId: f44b429a1d85e66ce89719817efd7acbfc7c4540
Domagoj Kriskovic 4 ヶ月 前
コミット
19c8434653

+ 1 - 0
services/web/app/src/Features/Project/ProjectController.mjs

@@ -475,6 +475,7 @@ const _ProjectController = {
       'testing-ai-usage',
       'wf-fake-non-english-suggestions',
       'editor-tabs',
+      'overleaf-code',
     ].filter(Boolean)
 
     const getUserValues = async userId =>

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

@@ -606,6 +606,7 @@
   "essential_cookies_only": "",
   "event_type": "",
   "example_project": "",
+  "execution_stopped": "",
   "existing_plan_active_until_term_end": "",
   "expand": "",
   "experiment_full_check_back_soon": "",
@@ -1062,6 +1063,7 @@
   "loading": "",
   "loading_github_repositories": "",
   "loading_prices": "",
+  "loading_python_runtime": "",
   "loading_recent_github_commits": "",
   "log_entry_description": "",
   "log_entry_maximum_entries": "",
@@ -1594,6 +1596,9 @@
   "revoke_license": "",
   "right": "",
   "role": "",
+  "run": "",
+  "run_current_script_to_see_output": "",
+  "run_python_code": "",
   "saml_auth_error": "",
   "saml_email_not_in_account_error_managed_users_2": "",
   "saml_identity_exists_error": "",
@@ -1834,6 +1839,7 @@
   "stop_on_first_error_enabled_description": "",
   "stop_on_first_error_enabled_title": "",
   "stop_on_validation_error": "",
+  "stop_python_execution": "",
   "stops_compiling_after_the_first_error_so_you_can_fix_issues_one_at_a_time": "",
   "store_your_work": "",
   "stretch_width_to_text": "",

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

@@ -19,6 +19,7 @@ export type LifecycleCallback = (
 
 export class PyodideWorkerClient {
   private worker: Worker
+  private baseAssetPath: string
   private listening = false
   private loaded = false
   private destroyed = false
@@ -28,19 +29,12 @@ export class PyodideWorkerClient {
   private lifecycleCallback: LifecycleCallback | null = null
 
   constructor(options: { baseAssetPath: string }) {
-    const { baseAssetPath } = options
-
-    this.worker = new Worker(
-      /* webpackChunkName: "pyodide-worker" */
-      new URL('./pyodide.worker.ts', import.meta.url),
-      { type: 'module' }
-    )
-
-    this.worker.addEventListener('message', this.receive.bind(this))
+    this.baseAssetPath = options.baseAssetPath
+    this.worker = this.createWorker()
 
     this.queueMessage({
       type: 'init',
-      baseAssetPath,
+      baseAssetPath: this.baseAssetPath,
     })
   }
 
@@ -80,6 +74,28 @@ export class PyodideWorkerClient {
     })
   }
 
+  stop(): void {
+    if (this.destroyed) {
+      return
+    }
+
+    // Terminate the current worker immediately
+    this.worker.terminate()
+    this.pendingMessages.length = 0
+
+    // 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.queueMessage({
+      type: 'init',
+      baseAssetPath: this.baseAssetPath,
+    })
+  }
+
   destroy() {
     if (this.destroyed) {
       return
@@ -95,6 +111,16 @@ export class PyodideWorkerClient {
     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)
@@ -103,7 +129,12 @@ export class PyodideWorkerClient {
     }
   }
 
-  private receive(event: MessageEvent<PyodideWorkerResponse>) {
+  private receive = (event: MessageEvent<PyodideWorkerResponse>) => {
+    // Discard messages from a previously terminated worker
+    if (event.target !== this.worker) {
+      return
+    }
+
     const response = event.data
 
     switch (response.type) {

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

@@ -1,4 +1,5 @@
 import { useCallback, useEffect, useRef, useState } 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'
@@ -10,6 +11,7 @@ import getMeta from '@/utils/meta'
 import { PyodideWorkerClient } from './pyodide-worker-client'
 
 export default function PythonOutputPane() {
+  const { t } = useTranslation()
   const { currentDocument, currentDocumentId } = useEditorOpenDocContext()
   const { pathInFolder } = useFileTreePathContext()
   const clientRef = useRef<PyodideWorkerClient | null>(null)
@@ -17,8 +19,8 @@ export default function PythonOutputPane() {
   const [isReady, setIsReady] = useState(false)
   const [output, setOutput] = useState<string[]>([])
   const [isRunning, setIsRunning] = useState(false)
-  const [isLoadingPyodide, setIsLoadingPyodide] = useState(true)
   const [error, setError] = useState<string | null>(null)
+  const [isLoadingPyodide, setIsLoadingPyodide] = useState(true)
 
   const appendOutput = useCallback((line: string) => {
     setOutput(previousOutput => [...previousOutput, line])
@@ -46,8 +48,8 @@ export default function PythonOutputPane() {
 
         case 'loading-failed':
           debugConsole.error('Failed to load Python runtime', event.error)
-          setError(formatError(event.error))
           setIsLoadingPyodide(false)
+          setError(formatError(event.error))
           setIsRunning(false)
           return
 
@@ -136,21 +138,39 @@ export default function PythonOutputPane() {
     }
   }, [buildCurrentDocumentSyncFile, isReady])
 
+  const handleStop = useCallback(() => {
+    const client = clientRef.current
+    if (!client) {
+      return
+    }
+
+    currentRequestIdRef.current = null
+    client.stop()
+    setIsRunning(false)
+    setIsReady(false)
+    setIsLoadingPyodide(true)
+    appendOutput(t('execution_stopped'))
+  }, [appendOutput, t])
+
   return (
     <div className="ide-redesign-python-output-pane">
       <OLButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid">
         <div className="toolbar-pdf-left">
           <div className="compile-button-group">
             <OLButton
-              onClick={handleRun}
-              variant="primary"
+              onClick={isRunning ? handleStop : handleRun}
+              variant={isRunning ? 'danger' : 'primary'}
               className="compile-button align-items-center py-0 px-3"
-              disabled={!isReady || isLoadingPyodide || isRunning}
-              aria-label="Run Python Code"
-              style={{ borderRadius: '12px' }}
+              disabled={!isRunning && !isReady}
+              aria-label={
+                isRunning ? t('stop_python_execution') : t('run_python_code')
+              }
             >
-              {isRunning ? 'Running...' : 'Run'}
-              <MaterialIcon type="play_arrow" className="ml-2" />
+              {isRunning ? t('stop') : t('run')}
+              <MaterialIcon
+                type={isRunning ? 'stop' : 'play_arrow'}
+                className="ml-2"
+              />
             </OLButton>
           </div>
         </div>
@@ -159,12 +179,12 @@ export default function PythonOutputPane() {
       <div className="ide-redesign-python-output-pane-body">
         {isLoadingPyodide && (
           <div className="ide-redesign-python-output-pane-placeholder">
-            Loading Python runtime...
+            {t('loading_python_runtime')}
           </div>
         )}
         {!isLoadingPyodide && !error && output.length === 0 && (
           <div className="ide-redesign-python-output-pane-placeholder">
-            Run the current script to see output.
+            {t('run_current_script_to_see_output')}
           </div>
         )}
         {error && (

+ 4 - 0
services/web/frontend/stylesheets/pages/editor/ide-redesign.scss

@@ -68,6 +68,10 @@
   background-color: var(--ide-redesign-background);
   color: var(--ide-redesign-color);
   border-top: 1px solid var(--border-divider);
+
+  .compile-button-group {
+    border-radius: var(--ds-border-radius-300);
+  }
 }
 
 .ide-redesign-python-output-pane-body {

+ 6 - 0
services/web/locales/en.json

@@ -778,6 +778,7 @@
   "everything_in_group_standard_plus": "Everything in Group Standard, plus…",
   "everything_in_standard_plus": "Everything in Standard, plus…",
   "example": "Example",
+  "execution_stopped": "Execution stopped.",
   "example_project": "Example project",
   "examples": "Examples",
   "examples_lowercase": "examples",
@@ -1368,6 +1369,7 @@
   "loading_content": "Creating Project",
   "loading_github_repositories": "Loading your GitHub repositories",
   "loading_prices": "loading prices",
+  "loading_python_runtime": "Loading Python runtime...",
   "loading_recent_github_commits": "Loading recent commits",
   "log_entry_description": "Log entry with level: __level__",
   "log_entry_maximum_entries": "Maximum log entries limit hit",
@@ -2080,6 +2082,9 @@
   "ro": "Romanian",
   "role": "Role",
   "ru": "Russian",
+  "run": "Run",
+  "run_current_script_to_see_output": "Run the current script to see output.",
+  "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.",
@@ -2362,6 +2367,7 @@
   "still_have_questions": "Still have questions?",
   "stop": "Stop",
   "stop_compile": "Stop compilation",
+  "stop_python_execution": "Stop Python Execution",
   "stop_on_first_error": "Stop on first error",
   "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",

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

@@ -32,7 +32,7 @@ class WorkerMock {
 
   emitMessage(message: unknown) {
     for (const listener of this.messageListeners) {
-      listener({ data: message } as MessageEvent)
+      listener({ data: message, target: this } as unknown as MessageEvent)
     }
   }
 }
@@ -153,4 +153,83 @@ describe('PyodideWorkerClient', function () {
 
     expect(worker.terminated).to.equal(true)
   })
+
+  describe('stop', function () {
+    it('terminates the current worker and creates a new one', function () {
+      const client = new PyodideWorkerClient({
+        baseAssetPath: BASE_ASSET_PATH,
+      })
+      const originalWorker = WorkerMock.instances[0]
+      originalWorker.emitMessage({ type: 'listening' })
+      originalWorker.emitMessage({ type: 'loaded' })
+
+      client.stop()
+
+      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 () {
+      const client = new PyodideWorkerClient({
+        baseAssetPath: BASE_ASSET_PATH,
+      })
+      const originalWorker = WorkerMock.instances[0]
+      originalWorker.emitMessage({ type: 'listening' })
+      originalWorker.emitMessage({ type: 'loaded' })
+
+      client.stop()
+
+      const newWorker = WorkerMock.instances[1]
+      expect(newWorker.postedMessages).to.have.length(0)
+
+      newWorker.emitMessage({ type: 'listening' })
+      expect(newWorker.postedMessages).to.deep.equal([
+        { type: 'init', baseAssetPath: BASE_ASSET_PATH },
+      ])
+    })
+
+    it('allows running code on the new worker after stop', function () {
+      const client = new PyodideWorkerClient({
+        baseAssetPath: BASE_ASSET_PATH,
+      })
+      const originalWorker = WorkerMock.instances[0]
+      originalWorker.emitMessage({ type: 'listening' })
+      originalWorker.emitMessage({ type: 'loaded' })
+
+      client.stop()
+
+      const newWorker = WorkerMock.instances[1]
+      newWorker.emitMessage({ type: 'listening' })
+      newWorker.emitMessage({ type: 'loaded' })
+
+      client.runCode('print("after stop")', {
+        requestId: 'main.py',
+        files: [],
+      })
+
+      const runRequest = newWorker.postedMessages.find(
+        (message: any) => message.type === 'run-code'
+      )
+      expect(runRequest).to.include({
+        type: 'run-code',
+        id: 'main.py',
+        code: 'print("after stop")',
+      })
+    })
+
+    it('is a no-op after destroy', function () {
+      const client = new PyodideWorkerClient({
+        baseAssetPath: BASE_ASSET_PATH,
+      })
+      const originalWorker = WorkerMock.instances[0]
+      originalWorker.emitMessage({ type: 'listening' })
+      originalWorker.emitMessage({ type: 'loaded' })
+
+      client.destroy()
+      client.stop()
+
+      // No new worker should have been created
+      expect(WorkerMock.instances).to.have.length(1)
+    })
+  })
 })