From 19c843465328430b3d29efb95062604cbca86329 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Wed, 8 Apr 2026 10:37:37 +0200 Subject: [PATCH] [web] Add stop button for Python code execution via worker termination GitOrigin-RevId: f44b429a1d85e66ce89719817efd7acbfc7c4540 --- .../Features/Project/ProjectController.mjs | 1 + .../web/frontend/extracted-translations.json | 6 ++ .../editor/python/pyodide-worker-client.ts | 53 +++++++++--- .../editor/python/python-output-pane.tsx | 42 +++++++--- .../pages/editor/ide-redesign.scss | 4 + services/web/locales/en.json | 6 ++ .../unit/editor/pyodide-worker-client.spec.ts | 81 ++++++++++++++++++- 7 files changed, 170 insertions(+), 23 deletions(-) diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 701e491118..7ab45293ac 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/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 => diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index eb66ba4d28..abe6263451 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts b/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts index e334177f41..0bfb01556f 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/python/pyodide-worker-client.ts +++ b/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) { + private receive = (event: MessageEvent) => { + // Discard messages from a previously terminated worker + if (event.target !== this.worker) { + return + } + const response = event.data switch (response.type) { diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx b/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx index 879b5c3a16..a03d60ae20 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx +++ b/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(null) @@ -17,8 +19,8 @@ export default function PythonOutputPane() { const [isReady, setIsReady] = useState(false) const [output, setOutput] = useState([]) const [isRunning, setIsRunning] = useState(false) - const [isLoadingPyodide, setIsLoadingPyodide] = useState(true) const [error, setError] = useState(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 (
- {isRunning ? 'Running...' : 'Run'} - + {isRunning ? t('stop') : t('run')} +
@@ -159,12 +179,12 @@ export default function PythonOutputPane() {
{isLoadingPyodide && (
- Loading Python runtime... + {t('loading_python_runtime')}
)} {!isLoadingPyodide && !error && output.length === 0 && (
- Run the current script to see output. + {t('run_current_script_to_see_output')}
)} {error && ( diff --git a/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss b/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss index 65c018b078..2ca1600a8c 100644 --- a/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss +++ b/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 { diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 221a163490..a17dfcc696 100644 --- a/services/web/locales/en.json +++ b/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. 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", diff --git a/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts b/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts index d56a804bc6..9af01d5189 100644 --- a/services/web/test/frontend/features/ide-react/unit/editor/pyodide-worker-client.spec.ts +++ b/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) + }) + }) })