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

GitOrigin-RevId: f44b429a1d85e66ce89719817efd7acbfc7c4540
This commit is contained in:
Domagoj Kriskovic
2026-04-13 08:04:07 +00:00
committed by Copybot
parent 50db96878d
commit 19c8434653
7 changed files with 170 additions and 23 deletions
@@ -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) {
@@ -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 && (