mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02:00
[web] Add stop button for Python code execution via worker termination
GitOrigin-RevId: f44b429a1d85e66ce89719817efd7acbfc7c4540
This commit is contained in:
committed by
Copybot
parent
50db96878d
commit
19c8434653
@@ -475,6 +475,7 @@ const _ProjectController = {
|
||||
'testing-ai-usage',
|
||||
'wf-fake-non-english-suggestions',
|
||||
'editor-tabs',
|
||||
'overleaf-code',
|
||||
].filter(Boolean)
|
||||
|
||||
const getUserValues = async userId =>
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user