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

GitOrigin-RevId: f44b429a1d85e66ce89719817efd7acbfc7c4540
This commit is contained in:
Domagoj Kriskovic
2026-04-08 10:37:37 +02:00
committed by Copybot
parent 50db96878d
commit 19c8434653
7 changed files with 170 additions and 23 deletions

View File

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

View File

@@ -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": "",

View File

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

View File

@@ -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 && (

View File

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

View File

@@ -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",

View File

@@ -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)
})
})
})