From b659ac2d92246970bbc8a91e8c24f1f7c3cd868f Mon Sep 17 00:00:00 2001 From: Eli Lamb Date: Wed, 1 Mar 2023 16:51:12 +1300 Subject: [PATCH 1/3] WIP worker pool, controls styling --- src/hooks/usePython.ts | 181 +++++++++++++++----------- src/providers/PythonProvider.tsx | 137 ++++++++++++++++++- website/docs/examples/playground.md | 80 +++++++++++- website/src/components/CodeEditor.tsx | 2 +- website/src/components/Controls.tsx | 6 +- website/tailwind.config.js | 15 +++ 6 files changed, 335 insertions(+), 86 deletions(-) diff --git a/src/hooks/usePython.ts b/src/hooks/usePython.ts index f1df799d..8b631829 100644 --- a/src/hooks/usePython.ts +++ b/src/hooks/usePython.ts @@ -20,7 +20,7 @@ interface UsePythonProps { export default function usePython(props?: UsePythonProps) { const { packages = {} } = props ?? {} - const [isLoading, setIsLoading] = useState(false) + // const [isLoading, setIsLoading] = useState(false) const [pyodideVersion, setPyodideVersion] = useState() const [isRunning, setIsRunning] = useState(false) const [output, setOutput] = useState([]) @@ -29,11 +29,17 @@ export default function usePython(props?: UsePythonProps) { const [pendingCode, setPendingCode] = useState() const [hasRun, setHasRun] = useState(false) + const [runnerId, setRunnerId] = useState() + const { packages: globalPackages, timeout, lazy, - terminateOnCompletion + terminateOnCompletion, + loading, + getRunner, + run, + output: runnerOutput } = useContext(PythonContext) const workerRef = useRef() @@ -49,24 +55,30 @@ export default function usePython(props?: UsePythonProps) { watchedModules } = useFilesystem({ runner: runnerRef?.current }) - const createWorker = () => { - const worker = new Worker( - new URL('../workers/python-worker', import.meta.url) - ) - workerRef.current = worker - } - useEffect(() => { - if (!lazy) { - // Spawn worker on mount - createWorker() + if (runnerOutput) { + console.log('runnerOutput', runnerOutput) } + }, [runnerOutput]) - // Cleanup worker on unmount - return () => { - cleanup() - } - }, []) + // const createWorker = () => { + // const worker = new Worker( + // new URL('../workers/python-worker', import.meta.url) + // ) + // workerRef.current = worker + // } + + // useEffect(() => { + // if (!lazy) { + // // Spawn worker on mount + // createWorker() + // } + + // // Cleanup worker on unmount + // return () => { + // cleanup() + // } + // }, []) const allPackages = useMemo(() => { const official = [ @@ -84,40 +96,43 @@ export default function usePython(props?: UsePythonProps) { return [official, micropip] }, [globalPackages, packages]) - const isReady = !isLoading && !!pyodideVersion + // const isReady = !isLoading && !!pyodideVersion + const isReady = !!runnerId - useEffect(() => { - if (workerRef.current && !isReady) { - const init = async () => { - try { - setIsLoading(true) - const runner: Remote = wrap(workerRef.current as Worker) - runnerRef.current = runner - - await runner.init( - proxy((msg: string) => { - // Suppress messages that are not useful for the user - if (suppressedMessages.includes(msg)) { - return - } - setOutput((prev) => [...prev, msg]) - }), - proxy(({ version }) => { - // The runner is ready once the Pyodide version has been set - setPyodideVersion(version) - console.debug('Loaded pyodide version:', version) - }), - allPackages - ) - } catch (error) { - console.error('Error loading Pyodide:', error) - } finally { - setIsLoading(false) - } - } - init() - } - }, [workerRef.current]) + const isLoading = loading && !isReady + + // useEffect(() => { + // if (workerRef.current && !isReady) { + // const init = async () => { + // try { + // setIsLoading(true) + // const runner: Remote = wrap(workerRef.current as Worker) + // runnerRef.current = runner + + // await runner.init( + // proxy((msg: string) => { + // // Suppress messages that are not useful for the user + // if (suppressedMessages.includes(msg)) { + // return + // } + // setOutput((prev) => [...prev, msg]) + // }), + // proxy(({ version }) => { + // // The runner is ready once the Pyodide version has been set + // setPyodideVersion(version) + // console.debug('Loaded pyodide version:', version) + // }), + // allPackages + // ) + // } catch (error) { + // console.error('Error loading Pyodide:', error) + // } finally { + // setIsLoading(false) + // } + // } + // init() + // } + // }, [workerRef.current]) // Immediately set stdout upon receiving new input useEffect(() => { @@ -127,15 +142,15 @@ export default function usePython(props?: UsePythonProps) { }, [output]) // React to ready state and run delayed code if pending - useEffect(() => { - if (pendingCode && isReady) { - const delayedRun = async () => { - await runPython(pendingCode) - setPendingCode(undefined) - } - delayedRun() - } - }, [pendingCode, isReady]) + // useEffect(() => { + // if (pendingCode && isReady) { + // const delayedRun = async () => { + // await runPython(pendingCode) + // setPendingCode(undefined) + // } + // delayedRun() + // } + // }, [pendingCode, isReady]) // React to run completion and run cleanup if worker should terminate on completion useEffect(() => { @@ -190,29 +205,43 @@ del sys setStdout('') setStderr('') - if (lazy && !isReady) { - // Spawn worker and set pending code - createWorker() - setPendingCode(code) + let newRunnerId + if (!runnerId) { + console.log('no runnerId, getting runner') + newRunnerId = await getRunner() + setRunnerId(newRunnerId) + } + + const r = runnerId || newRunnerId + + if (!r) { + console.log('no runnerId, returning') return } + // if (lazy && !isReady) { + // // Spawn worker and set pending code + // createWorker() + // setPendingCode(code) + // return + // } + code = `${pythonRunnerCode}\n\nrun(${JSON.stringify( code )}, ${JSON.stringify(preamble)})` - if (!isReady) { - throw new Error('Pyodide is not loaded yet') - } + // if (!isReady) { + // throw new Error('Pyodide is not loaded yet') + // } let timeoutTimer try { setIsRunning(true) setHasRun(true) // Clear output setOutput([]) - if (!isReady || !runnerRef.current) { - throw new Error('Pyodide is not loaded yet') - } + // if (!isReady || !runnerRef.current) { + // throw new Error('Pyodide is not loaded yet') + // } if (timeout > 0) { timeoutTimer = setTimeout(() => { setStdout('') @@ -221,9 +250,9 @@ del sys }, timeout) } if (watchedModules.size > 0) { - await runnerRef.current.run(moduleReloadCode(watchedModules)) + await run(r, moduleReloadCode(watchedModules)) } - await runnerRef.current.run(code) + await run(r, code) // eslint-disable-next-line } catch (error: any) { setStderr('Traceback (most recent call last):\n' + error.message) @@ -232,7 +261,13 @@ del sys clearTimeout(timeoutTimer) } }, - [lazy, isReady, timeout, watchedModules] + [ + runnerId, + lazy, + // isReady, + timeout, + watchedModules + ] ) const interruptExecution = () => { @@ -242,7 +277,7 @@ del sys setOutput([]) // Spawn new worker - createWorker() + // createWorker() } const cleanup = () => { diff --git a/src/providers/PythonProvider.tsx b/src/providers/PythonProvider.tsx index dff27156..32d59ce5 100644 --- a/src/providers/PythonProvider.tsx +++ b/src/providers/PythonProvider.tsx @@ -1,15 +1,24 @@ -import { createContext } from 'react' +import { proxy, Remote, wrap } from 'comlink' +import { createContext, useEffect, useRef, useState } from 'react' import { Packages } from '../types/Packages' +import { PythonRunner } from '../types/Runner' const PythonContext = createContext({ packages: {} as Packages, timeout: 0, lazy: false, - terminateOnCompletion: false + terminateOnCompletion: false, + loading: false, + getRunner: async () => '', + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function + run: async (id: string, code: string) => {}, + output: new Map() }) export const suppressedMessages = ['Python initialization complete'] +const BUFFER = 3 + interface PythonProviderProps { packages?: Packages timeout?: number @@ -27,13 +36,135 @@ function PythonProvider(props: PythonProviderProps) { terminateOnCompletion = false } = props + const workerRef = useRef>(new Map()) + const runnerRef = useRef>>(new Map()) + const assignedRunners = useRef>(new Set()) + + const [loading, setLoading] = useState(true) + const [output, setOutput] = useState>(new Map()) + + useEffect(() => { + if (!lazy) { + // print buffer in big banner + console.log( + '%cWorker Pool 🏊‍♀️', + 'background: #000; color: #fff; font-size: 2rem; font-weight: bold; padding: 0.5rem 1rem; border-radius: 0.5rem;' + ) + console.log( + '%cBuffer size: ' + BUFFER, + 'background: #000; color: #fff; font-size: 1rem; font-weight: bold; padding: 0.5rem 1rem; border-radius: 0.5rem;' + ) + + for (let i = 0; i < BUFFER; i++) { + createInstance() + } + } + }, []) + + const createInstance = async () => { + try { + const availableRunners = Array.from(runnerRef.current.keys()).filter( + (id) => !assignedRunners.current.has(id) + ) + if (availableRunners.length === 0) { + setLoading(true) + } + + const id = crypto.randomUUID() + + const worker = new Worker( + new URL('../workers/python-worker', import.meta.url) + ) + workerRef.current.set(id, worker) + + const runner: Remote = wrap(worker) + runnerRef.current.set(id, runner) + + await runner.init( + proxy((msg: string) => { + // Suppress messages that are not useful for the user + if (suppressedMessages.includes(msg)) { + return + } + console.log('- Python output:', msg) + + setOutput((prev) => { + const prevOutput = prev.get(id) ?? [] + return prev.set(id, [...prevOutput, msg]) + }) + }), + proxy(({ version }) => { + console.debug('Loaded pyodide version:', version) + setLoading(false) + }), + [packages.official ?? [], packages.micropip ?? []] + ) + } catch (error) { + console.error('Error loading Pyodide:', error) + } + } + + const getRunner = async () => { + if (lazy) { + await createInstance() + } + + console.log(runnerRef.current.keys(), assignedRunners) + + // get the first available runner + const availableRunners = Array.from(runnerRef.current.keys()).filter( + (id) => !assignedRunners.current.has(id) + ) + + const id = availableRunners[0] + if (!id) { + throw new Error('No runner available') + } + + assignedRunners.current.add(id) + + // if available is less than buffer, create a new instance + if (availableRunners.length - 1 < BUFFER) { + createInstance() + } + + return id + } + + const run = async (id: string, code: string) => { + console.log('Python run', id) + + const runner = runnerRef.current.get(id) + if (!runner) { + throw new Error('Runner not found') + } + await runner.run(code) + + if (terminateOnCompletion) { + console.log('Python terminateOnCompletion', id) + + runnerRef.current.delete(id) + assignedRunners.current.delete(id) + + const worker = workerRef.current.get(id) + if (!worker) { + throw new Error('Worker not found') + } + worker.terminate() + } + } + return ( diff --git a/website/docs/examples/playground.md b/website/docs/examples/playground.md index f11f7113..64157673 100644 --- a/website/docs/examples/playground.md +++ b/website/docs/examples/playground.md @@ -8,13 +8,81 @@ draft: true Used for testing bugs etc. ```python -with open("/hello.txt", "w") as fh: - fh.write("hello world!") - print("done") +print(1) ``` ```python -with open("/hello.txt", "r") as fh: - data = fh.read() -print(data) +print(2) +``` + +```python +print(3) +``` + +```python +print(4) +``` + +```python +print(5) +``` + +```python +print(6) +``` + +```python +print(7) +``` + +```python +print(8) +``` + +```python +print(9) +``` + +```python +print(10) +``` + +```python +print(11) +``` + +```python +print(12) +``` + +```python +print(13) +``` + +```python +print(14) +``` + +```python +print(15) +``` + +```python +print(16) +``` + +```python +print(17) +``` + +```python +print(18) +``` + +```python +print(19) +``` + +```python +print(20) ``` diff --git a/website/src/components/CodeEditor.tsx b/website/src/components/CodeEditor.tsx index 8340654c..6086ccbb 100644 --- a/website/src/components/CodeEditor.tsx +++ b/website/src/components/CodeEditor.tsx @@ -96,7 +96,7 @@ export default function CodeEditor(props: CodeEditorProps) { mode="python" name="CodeBlock" fontSize="0.9rem" - className="min-h-[4rem] overflow-clip rounded shadow-md" + className="min-h-[4rem] overflow-clip rounded border border-solid border-gray-300/25 shadow-md" theme={colorMode === 'dark' ? 'idle_fingers' : 'textmate'} onChange={(newValue) => setInput(newValue)} width="100%" diff --git a/website/src/components/Controls.tsx b/website/src/components/Controls.tsx index 562856fd..19a21fbe 100644 --- a/website/src/components/Controls.tsx +++ b/website/src/components/Controls.tsx @@ -17,7 +17,7 @@ export default function Controls(props: ControlProps) { return (
-
+
{visibleItems.map((item, i) => (