Skip to content

Commit f6470dc

Browse files
Multiple Worker based Terminals (pyscript#1948)
Multiple Worker based Terminals
1 parent a9717af commit f6470dc

File tree

9 files changed

+204
-147
lines changed

9 files changed

+204
-147
lines changed

pyscript.core/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyscript.core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pyscript/core",
3-
"version": "0.3.19",
3+
"version": "0.3.20",
44
"type": "module",
55
"description": "PyScript",
66
"module": "./index.js",

pyscript.core/src/plugins/py-terminal.js

Lines changed: 160 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -14,31 +14,73 @@ const notifyAndThrow = (message) => {
1414
throw new Error(message);
1515
};
1616

17+
const notParsedYet = (script) => !bootstrapped.has(script);
18+
19+
const onceOnMain = ({ attributes: { worker } }) => !worker;
20+
21+
const bootstrapped = new WeakSet();
22+
23+
let addStyle = true;
24+
25+
// this callback will be serialized as string and it never needs
26+
// to be invoked multiple times. Each xworker here is bootstrapped
27+
// only once thanks to the `sync.is_pyterminal()` check.
28+
const workerReady = ({ interpreter, io, run }, { sync }) => {
29+
if (!sync.is_pyterminal()) return;
30+
31+
// in workers it's always safe to grab the polyscript currentScript
32+
run("from polyscript.currentScript import terminal as __terminal__");
33+
34+
// This part is inevitably duplicated as external scope
35+
// can't be reached by workers out of the box.
36+
// The detail is that here we use sync though, not readline.
37+
const decoder = new TextDecoder();
38+
let data = "";
39+
const generic = {
40+
isatty: true,
41+
write(buffer) {
42+
data = decoder.decode(buffer);
43+
sync.pyterminal_write(data);
44+
return buffer.length;
45+
},
46+
};
47+
interpreter.setStdout(generic);
48+
interpreter.setStderr(generic);
49+
interpreter.setStdin({
50+
isatty: true,
51+
stdin: () => sync.pyterminal_read(data),
52+
});
53+
54+
io.stderr = (error) => {
55+
sync.pyterminal_write(`${error.message || error}\n`);
56+
};
57+
};
58+
1759
const pyTerminal = async () => {
1860
const terminals = document.querySelectorAll(SELECTOR);
1961

20-
// no results will look further for runtime nodes
21-
if (!terminals.length) return;
62+
const unknown = [].filter.call(terminals, notParsedYet);
2263

23-
// if we arrived this far, let's drop the MutationObserver
24-
// as we only support one terminal per page (right now).
25-
mo.disconnect();
64+
// no results will look further for runtime nodes
65+
if (!unknown.length) return;
66+
// early flag elements as known to avoid concurrent
67+
// MutationObserver invokes of this async handler
68+
else unknown.forEach(bootstrapped.add, bootstrapped);
2669

2770
// we currently support only one terminal as in "classic"
28-
if (terminals.length > 1) notifyAndThrow("You can use at most 1 terminal.");
29-
30-
const [element] = terminals;
31-
// hopefully to be removed in the near future!
32-
if (element.matches('script[type="mpy"],mpy-script'))
33-
notifyAndThrow("Unsupported terminal.");
71+
if ([].filter.call(terminals, onceOnMain).length > 1)
72+
notifyAndThrow("You can use at most 1 main terminal");
3473

3574
// import styles lazily
36-
document.head.append(
37-
Object.assign(document.createElement("link"), {
38-
rel: "stylesheet",
39-
href: new URL("./xterm.css", import.meta.url),
40-
}),
41-
);
75+
if (addStyle) {
76+
addStyle = false;
77+
document.head.append(
78+
Object.assign(document.createElement("link"), {
79+
rel: "stylesheet",
80+
href: new URL("./xterm.css", import.meta.url),
81+
}),
82+
);
83+
}
4284

4385
// lazy load these only when a valid terminal is found
4486
const [{ Terminal }, { Readline }, { FitAddon }] = await Promise.all([
@@ -47,136 +89,113 @@ const pyTerminal = async () => {
4789
import(/* webpackIgnore: true */ "../3rd-party/xterm_addon-fit.js"),
4890
]);
4991

50-
const readline = new Readline();
51-
52-
// common main thread initialization for both worker
53-
// or main case, bootstrapping the terminal on its target
54-
const init = (options) => {
55-
let target = element;
56-
const selector = element.getAttribute("target");
57-
if (selector) {
58-
target =
59-
document.getElementById(selector) ||
60-
document.querySelector(selector);
61-
if (!target) throw new Error(`Unknown target ${selector}`);
62-
} else {
63-
target = document.createElement("py-terminal");
64-
target.style.display = "block";
65-
element.after(target);
66-
}
67-
const terminal = new Terminal({
68-
theme: {
69-
background: "#191A19",
70-
foreground: "#F5F2E7",
71-
},
72-
...options,
73-
});
74-
const fitAddon = new FitAddon();
75-
terminal.loadAddon(fitAddon);
76-
terminal.loadAddon(readline);
77-
terminal.open(target);
78-
fitAddon.fit();
79-
terminal.focus();
80-
defineProperty(element, "terminal", { value: terminal });
81-
return terminal;
82-
};
83-
84-
// branch logic for the worker
85-
if (element.hasAttribute("worker")) {
86-
// when the remote thread onReady triggers:
87-
// setup the interpreter stdout and stderr
88-
const workerReady = ({ interpreter, io, run }, { sync }) => {
89-
// in workers it's always safe to grab the polyscript currentScript
90-
run(
91-
"from polyscript.currentScript import terminal as __terminal__",
92-
);
93-
sync.pyterminal_drop_hooks();
94-
95-
// This part is inevitably duplicated as external scope
96-
// can't be reached by workers out of the box.
97-
// The detail is that here we use sync though, not readline.
98-
const decoder = new TextDecoder();
99-
let data = "";
100-
const generic = {
101-
isatty: true,
102-
write(buffer) {
103-
data = decoder.decode(buffer);
104-
sync.pyterminal_write(data);
105-
return buffer.length;
92+
for (const element of unknown) {
93+
// hopefully to be removed in the near future!
94+
if (element.matches('script[type="mpy"],mpy-script'))
95+
notifyAndThrow("Unsupported terminal.");
96+
97+
const readline = new Readline();
98+
99+
// common main thread initialization for both worker
100+
// or main case, bootstrapping the terminal on its target
101+
const init = (options) => {
102+
let target = element;
103+
const selector = element.getAttribute("target");
104+
if (selector) {
105+
target =
106+
document.getElementById(selector) ||
107+
document.querySelector(selector);
108+
if (!target) throw new Error(`Unknown target ${selector}`);
109+
} else {
110+
target = document.createElement("py-terminal");
111+
target.style.display = "block";
112+
element.after(target);
113+
}
114+
const terminal = new Terminal({
115+
theme: {
116+
background: "#191A19",
117+
foreground: "#F5F2E7",
106118
},
107-
};
108-
interpreter.setStdout(generic);
109-
interpreter.setStderr(generic);
110-
interpreter.setStdin({
111-
isatty: true,
112-
stdin: () => sync.pyterminal_read(data),
119+
...options,
113120
});
114-
115-
io.stderr = (error) => {
116-
sync.pyterminal_write(`${error.message || error}\n`);
117-
};
121+
const fitAddon = new FitAddon();
122+
terminal.loadAddon(fitAddon);
123+
terminal.loadAddon(readline);
124+
terminal.open(target);
125+
fitAddon.fit();
126+
terminal.focus();
127+
defineProperty(element, "terminal", { value: terminal });
128+
return terminal;
118129
};
119130

120-
// add a hook on the main thread to setup all sync helpers
121-
// also bootstrapping the XTerm target on main
122-
hooks.main.onWorker.add(function worker(_, xworker) {
123-
hooks.main.onWorker.delete(worker);
124-
init({
125-
disableStdin: false,
126-
cursorBlink: true,
127-
cursorStyle: "block",
128-
});
129-
xworker.sync.pyterminal_read = readline.read.bind(readline);
130-
xworker.sync.pyterminal_write = readline.write.bind(readline);
131-
// allow a worker to drop main thread hooks ASAP
132-
xworker.sync.pyterminal_drop_hooks = () => {
133-
hooks.worker.onReady.delete(workerReady);
134-
};
135-
});
136-
137-
// setup remote thread JS/Python code for whenever the
138-
// worker is ready to become a terminal
139-
hooks.worker.onReady.add(workerReady);
140-
} else {
141-
// in the main case, just bootstrap XTerm without
142-
// allowing any input as that's not possible / awkward
143-
hooks.main.onReady.add(function main({ interpreter, io, run }) {
144-
console.warn("py-terminal is read only on main thread");
145-
hooks.main.onReady.delete(main);
146-
147-
// on main, it's easy to trash and clean the current terminal
148-
globalThis.__py_terminal__ = init({
149-
disableStdin: true,
150-
cursorBlink: false,
151-
cursorStyle: "underline",
152-
});
153-
run("from js import __py_terminal__ as __terminal__");
154-
delete globalThis.__py_terminal__;
155-
156-
// This part is inevitably duplicated as external scope
157-
// can't be reached by workers out of the box.
158-
// The detail is that here we use readline here, not sync.
159-
const decoder = new TextDecoder();
160-
let data = "";
161-
const generic = {
162-
isatty: true,
163-
write(buffer) {
164-
data = decoder.decode(buffer);
165-
readline.write(data);
166-
return buffer.length;
167-
},
168-
};
169-
interpreter.setStdout(generic);
170-
interpreter.setStderr(generic);
171-
interpreter.setStdin({
172-
isatty: true,
173-
stdin: () => readline.read(data),
131+
// branch logic for the worker
132+
if (element.hasAttribute("worker")) {
133+
// add a hook on the main thread to setup all sync helpers
134+
// also bootstrapping the XTerm target on main *BUT* ...
135+
hooks.main.onWorker.add(function worker(_, xworker) {
136+
// ... as multiple workers will add multiple callbacks
137+
// be sure no xworker is ever initialized twice!
138+
if (bootstrapped.has(xworker)) return;
139+
bootstrapped.add(xworker);
140+
141+
// still cleanup this callback for future scripts/workers
142+
hooks.main.onWorker.delete(worker);
143+
144+
init({
145+
disableStdin: false,
146+
cursorBlink: true,
147+
cursorStyle: "block",
148+
});
149+
150+
xworker.sync.is_pyterminal = () => true;
151+
xworker.sync.pyterminal_read = readline.read.bind(readline);
152+
xworker.sync.pyterminal_write = readline.write.bind(readline);
174153
});
175154

176-
io.stderr = (error) => {
177-
readline.write(`${error.message || error}\n`);
178-
};
179-
});
155+
// setup remote thread JS/Python code for whenever the
156+
// worker is ready to become a terminal
157+
hooks.worker.onReady.add(workerReady);
158+
} else {
159+
// in the main case, just bootstrap XTerm without
160+
// allowing any input as that's not possible / awkward
161+
hooks.main.onReady.add(function main({ interpreter, io, run }) {
162+
console.warn("py-terminal is read only on main thread");
163+
hooks.main.onReady.delete(main);
164+
165+
// on main, it's easy to trash and clean the current terminal
166+
globalThis.__py_terminal__ = init({
167+
disableStdin: true,
168+
cursorBlink: false,
169+
cursorStyle: "underline",
170+
});
171+
run("from js import __py_terminal__ as __terminal__");
172+
delete globalThis.__py_terminal__;
173+
174+
// This part is inevitably duplicated as external scope
175+
// can't be reached by workers out of the box.
176+
// The detail is that here we use readline here, not sync.
177+
const decoder = new TextDecoder();
178+
let data = "";
179+
const generic = {
180+
isatty: true,
181+
write(buffer) {
182+
data = decoder.decode(buffer);
183+
readline.write(data);
184+
return buffer.length;
185+
},
186+
};
187+
interpreter.setStdout(generic);
188+
interpreter.setStderr(generic);
189+
interpreter.setStdin({
190+
isatty: true,
191+
stdin: () => readline.read(data),
192+
});
193+
194+
io.stderr = (error) => {
195+
readline.write(`${error.message || error}\n`);
196+
};
197+
});
198+
}
180199
}
181200
};
182201

pyscript.core/src/sync.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
export default {
2+
// allow pyterminal checks to bootstrap
3+
is_pyterminal: () => false,
4+
25
/**
36
* 'Sleep' for the given number of seconds. Used to implement Python's time.sleep in Worker threads.
47
* @param {number} seconds The number of seconds to sleep.

pyscript.core/test/mpy.spec.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,8 @@ test('Pyodide + terminal on Worker', async ({ page }) => {
7373
await page.goto('http://localhost:8080/test/py-terminal-worker.html');
7474
await page.waitForSelector('html.ok');
7575
});
76+
77+
test('Pyodide + multiple terminals via Worker', async ({ page }) => {
78+
await page.goto('http://localhost:8080/test/py-terminals.html');
79+
await page.waitForSelector('html.first.second');
80+
});

pyscript.core/test/py-terminal-worker.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<style>.xterm { padding: .5rem; }</style>
1010
</head>
1111
<body>
12-
<py-script src="terminal.py" worker terminal></py-script>
12+
<script type="py" src="terminal.py" worker terminal></script>
13+
<script type="py" src="terminal.py" worker terminal></script>
1314
</body>
1415
</html>

0 commit comments

Comments
 (0)