gh-136251: Improvements to WASM demo REPL (GH-136252)

Co-authored-by: Hood Chatham <roberthoodchatham@gmail.com>
This commit is contained in:
adam j hartz 2025-07-21 05:56:45 -04:00 committed by GitHub
parent 9c7b2af73d
commit d1d526afe7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 327 additions and 56 deletions

View file

@ -1096,7 +1096,7 @@ $(DLLLIBRARY) libpython$(LDVERSION).dll.a: $(LIBRARY_OBJS)
# wasm32-emscripten browser web example # wasm32-emscripten browser web example
WEBEX_DIR=$(srcdir)/Tools/wasm/emscripten/web_example/ WEBEX_DIR=$(srcdir)/Tools/wasm/emscripten/web_example/
web_example/python.html: $(WEBEX_DIR)/python.html web_example/index.html: $(WEBEX_DIR)/index.html
@mkdir -p web_example @mkdir -p web_example
@cp $< $@ @cp $< $@
@ -1124,7 +1124,7 @@ web_example/python.mjs web_example/python.wasm: $(BUILDPYTHON)
cp python.wasm web_example/python.wasm cp python.wasm web_example/python.wasm
.PHONY: web_example .PHONY: web_example
web_example: web_example/python.mjs web_example/python.worker.mjs web_example/python.html web_example/server.py $(WEB_STDLIB) web_example: web_example/python.mjs web_example/python.worker.mjs web_example/index.html web_example/server.py $(WEB_STDLIB)
############################################################################ ############################################################################
# Header files # Header files

View file

@ -0,0 +1 @@
Fixes and usability improvements for ``Tools/wasm/emscripten/web_example``

View file

@ -100,7 +100,7 @@ EM_JS_MACROS(void, _emscripten_promising_main_js, (void), {
return; return;
} }
const origResolveGlobalSymbol = resolveGlobalSymbol; const origResolveGlobalSymbol = resolveGlobalSymbol;
if (!Module.onExit && process?.exit) { if (!Module.onExit && globalThis?.process?.exit) {
Module.onExit = (code) => process.exit(code); Module.onExit = (code) => process.exit(code);
} }
// * wrap the main symbol with WebAssembly.promising, // * wrap the main symbol with WebAssembly.promising,

View file

@ -86,11 +86,11 @@ CLI you will need to write your own alternative to `node_entry.mjs`.
### The Web Example ### The Web Example
When building for Emscripten, the web example will be built automatically. It is When building for Emscripten, the web example will be built automatically. It
in the ``web_example`` directory. To run the web example, ``cd`` into the is in the ``web_example`` directory. To run the web example, ``cd`` into the
``web_example`` directory, then run ``python server.py``. This will start a web ``web_example`` directory, then run ``python server.py``. This will start a web
server; you can then visit ``http://localhost:8000/python.html`` in a browser to server; you can then visit ``http://localhost:8000/`` in a browser to see a
see a simple REPL example. simple REPL example.
The web example relies on a bug fix in Emscripten version 3.1.73 so if you build The web example relies on a bug fix in Emscripten version 3.1.73 so if you build
with earlier versions of Emscripten it may not work. The web example uses with earlier versions of Emscripten it may not work. The web example uses

View file

@ -1,31 +1,39 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="Katie Bell" /> <meta name="author" content="Katie Bell, Adam Hartz">
<meta name="description" content="Simple REPL for Python WASM" /> <meta name="description" content="Simple REPL for Python WASM">
<title>wasm-python terminal</title> <title>wasm-python terminal</title>
<link <link
rel="stylesheet" rel="stylesheet"
href="https://unpkg.com/xterm@4.18.0/css/xterm.css" href="https://unpkg.com/xterm@4.18.0/css/xterm.css"
crossorigin crossorigin
integrity="sha384-4eEEn/eZgVHkElpKAzzPx/Kow/dTSgFk1BNe+uHdjHa+NkZJDh5Vqkq31+y7Eycd" integrity="sha384-4eEEn/eZgVHkElpKAzzPx/Kow/dTSgFk1BNe+uHdjHa+NkZJDh5Vqkq31+y7Eycd"
/> >
<style> <style>
body { body {
font-family: arial; font-family: arial;
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
} }
#code { #editor {
padding: 5px;
border: 1px solid black;
width: 100%; width: 100%;
height: 180px; height: 300px;
} }
#info { #info {
padding-top: 20px; padding-top: 20px;
} }
.error {
border: 1px solid red;
background-color: #ffd9d9;
padding: 5px;
margin-top: 20px;
}
.button-container { .button-container {
display: flex; display: flex;
justify-content: end; justify-content: end;
@ -41,8 +49,14 @@
src="https://unpkg.com/xterm@4.18.0/lib/xterm.js" src="https://unpkg.com/xterm@4.18.0/lib/xterm.js"
crossorigin crossorigin
integrity="sha384-yYdNmem1ioP5Onm7RpXutin5A8TimLheLNQ6tnMi01/ZpxXdAwIm2t4fJMx1Djs+" integrity="sha384-yYdNmem1ioP5Onm7RpXutin5A8TimLheLNQ6tnMi01/ZpxXdAwIm2t4fJMx1Djs+"
/> ></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.43.1/ace.js"
crossorigin
integrity="sha512-kmA5vhcxOkZI0ReiKJMGNb8/KKbgbExIlnt6aXuPtl86AgHBEi6OHHOz2wsTazBDGZKxe7fmiE+pIuZJQks4+A=="
></script>
<script type="module"> <script type="module">
const _magic_ctrlc_string = "__WASM_REPL_CTRLC_" + (Date.now()) + "__";
class WorkerManager { class WorkerManager {
constructor( constructor(
workerURL, workerURL,
@ -132,11 +146,14 @@
class WasmTerminal { class WasmTerminal {
constructor() { constructor() {
this.inputBuffer = new BufferQueue(); try {
this.input = ""; this.history = JSON.parse(sessionStorage.getItem('__python_wasm_repl.history'));
this.resolveInput = null; this.historyBuffer = this.history.slice();
this.activeInput = false; } catch(e) {
this.inputStartCursor = null; this.history = [];
this.historyBuffer = [];
}
this.reset();
this.xterm = new Terminal({ this.xterm = new Terminal({
scrollback: 10000, scrollback: 10000,
@ -155,6 +172,18 @@
this.xterm.onData(this.handleTermData); this.xterm.onData(this.handleTermData);
} }
reset() {
this.inputBuffer = new BufferQueue();
this.input = "";
this.resolveInput = null;
this.activeInput = false;
this.inputStartCursor = null;
this.cursorPosition = 0;
this.historyIndex = -1;
this.beforeHistoryNav = "";
}
open(container) { open(container) {
this.xterm.open(container); this.xterm.open(container);
} }
@ -186,9 +215,34 @@
if (!(ord === 0x1b || ord == 0x7f || ord < 32)) { if (!(ord === 0x1b || ord == 0x7f || ord < 32)) {
this.inputBuffer.addData(data); this.inputBuffer.addData(data);
} }
// TODO: Handle ANSI escape sequences // TODO: Handle more escape sequences?
} else if (ord === 0x1b) { } else if (ord === 0x1b) {
// Handle special characters // Handle special characters
switch (data.slice(1)) {
case "[A": // up
this.historyBack();
break;
case "[B": // down
this.historyForward();
break;
case "[C": // right
this.cursorRight();
break;
case "[D": // left
this.cursorLeft();
break;
case "[H": // home key
this.cursorHome(true);
break;
case "[F": // end key
this.cursorEnd(true);
break;
case "[3~": // delete key
this.deleteAtCursor();
break;
default:
break;
}
} else if (ord < 32 || ord === 0x7f) { } else if (ord < 32 || ord === 0x7f) {
switch (data) { switch (data) {
case "\x0c": // CTRL+L case "\x0c": // CTRL+L
@ -201,8 +255,18 @@
this.input + this.writeLine("\n"), this.input + this.writeLine("\n"),
); );
this.input = ""; this.input = "";
this.cursorPosition = 0;
this.activeInput = false; this.activeInput = false;
break; break;
case "\x03": // CTRL+C
this.input = "";
this.cursorPosition = 0;
this.historyIndex = -1;
this.resolveInput(_magic_ctrlc_string + "\n");
break;
case "\x09": // TAB
this.handleTab();
break;
case "\x7F": // BACKSPACE case "\x7F": // BACKSPACE
case "\x08": // CTRL+H case "\x08": // CTRL+H
this.handleCursorErase(true); this.handleCursorErase(true);
@ -211,14 +275,20 @@
// Send empty input // Send empty input
if (this.input === "") { if (this.input === "") {
this.resolveInput(""); this.resolveInput("");
this.cursorPosition = 0;
this.activeInput = false; this.activeInput = false;
} }
} }
} else { } else {
this.handleCursorInsert(data); this.handleCursorInsert(data);
this.updateHistory();
} }
}; };
clearLine() {
this.xterm.write("\x1b[K");
}
writeLine(line) { writeLine(line) {
this.xterm.write(line.slice(0, -1)); this.xterm.write(line.slice(0, -1));
this.xterm.write("\r\n"); this.xterm.write("\r\n");
@ -226,8 +296,36 @@
} }
handleCursorInsert(data) { handleCursorInsert(data) {
this.input += data; const trailing = this.input.slice(this.cursorPosition);
this.input =
this.input.slice(0, this.cursorPosition) +
data +
trailing;
this.cursorPosition += data.length;
this.xterm.write(data); this.xterm.write(data);
if (trailing.length !== 0) {
this.xterm.write(trailing);
this.xterm.write("\x1b[" + trailing.length + "D");
}
this.updateHistory();
}
handleTab() {
// handle tabs: from the current position, add spaces until
// this.cursorPosition is a multiple of 4.
const prefix = this.input.slice(0, this.cursorPosition);
const suffix = this.input.slice(this.cursorPosition);
const count = 4 - (this.cursorPosition % 4);
const toAdd = " ".repeat(count);
this.input = prefix + toAdd + suffix;
this.cursorHome(false);
this.clearLine();
this.xterm.write(this.input);
if (suffix) {
this.xterm.write("\x1b[" + suffix.length + "D");
}
this.cursorPosition += count;
this.updateHistory();
} }
handleCursorErase() { handleCursorErase() {
@ -238,9 +336,113 @@
) { ) {
return; return;
} }
this.input = this.input.slice(0, -1); const trailing = this.input.slice(this.cursorPosition);
this.xterm.write("\x1B[D"); this.input =
this.xterm.write("\x1B[P"); this.input.slice(0, this.cursorPosition - 1) + trailing;
this.cursorLeft();
this.clearLine();
if (trailing.length !== 0) {
this.xterm.write(trailing);
this.xterm.write("\x1b[" + trailing.length + "D");
}
this.updateHistory();
}
deleteAtCursor() {
if (this.cursorPosition < this.input.length) {
const trailing = this.input.slice(
this.cursorPosition + 1,
);
this.input =
this.input.slice(0, this.cursorPosition) + trailing;
this.clearLine();
if (trailing.length !== 0) {
this.xterm.write(trailing);
this.xterm.write("\x1b[" + trailing.length + "D");
}
this.updateHistory();
}
}
cursorRight() {
if (this.cursorPosition < this.input.length) {
this.cursorPosition += 1;
this.xterm.write("\x1b[C");
}
}
cursorLeft() {
if (this.cursorPosition > 0) {
this.cursorPosition -= 1;
this.xterm.write("\x1b[D");
}
}
cursorHome(updatePosition) {
if (this.cursorPosition > 0) {
this.xterm.write("\x1b[" + this.cursorPosition + "D");
if (updatePosition) {
this.cursorPosition = 0;
}
}
}
cursorEnd() {
if (this.cursorPosition < this.input.length) {
this.xterm.write(
"\x1b[" +
(this.input.length - this.cursorPosition) +
"C",
);
this.cursorPosition = this.input.length;
}
}
updateHistory() {
if (this.historyIndex !== -1) {
this.historyBuffer[this.historyIndex] = this.input;
} else {
this.beforeHistoryNav = this.input;
}
}
historyBack() {
if (this.history.length === 0) {
return;
} else if (this.historyIndex === -1) {
// we're not currently navigating the history; store
// the current command and then look at the end of our
// history buffer
this.beforeHistoryNav = this.input;
this.historyIndex = this.history.length - 1;
} else if (this.historyIndex > 0) {
this.historyIndex -= 1;
}
this.input = this.historyBuffer[this.historyIndex];
this.cursorHome(false);
this.clearLine();
this.xterm.write(this.input);
this.cursorPosition = this.input.length;
}
historyForward() {
if (this.history.length === 0 || this.historyIndex === -1) {
// we're not currently navigating the history; NOP.
return;
} else if (this.historyIndex < this.history.length - 1) {
this.historyIndex += 1;
this.input = this.historyBuffer[this.historyIndex];
} else if (this.historyIndex == this.history.length - 1) {
// we're coming back from the last history value; reset
// the input to whatever it was when we started going
// through the history
this.input = this.beforeHistoryNav;
this.historyIndex = -1;
}
this.cursorHome(false);
this.clearLine();
this.xterm.write(this.input);
this.cursorPosition = this.input.length;
} }
prompt = async () => { prompt = async () => {
@ -263,12 +465,29 @@
// Hack to ensure cursor input start doesn't end up after user input // Hack to ensure cursor input start doesn't end up after user input
setTimeout(() => { setTimeout(() => {
this.handleCursorInsert( this.handleCursorInsert(
this.inputBuffer.nextLine(), this.inputBuffer.nextLine()
); );
}, 1); }, 1);
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.resolveInput = (value) => { this.resolveInput = (value) => {
if (
value.replace(/\s/g, "").length != 0 &&
value != _magic_ctrlc_string + "\n"
) {
if (this.historyIndex !== -1) {
this.historyBuffer[this.historyIndex] =
this.history[this.historyIndex];
}
this.history.push(value.slice(0, -1));
this.historyBuffer.push(value.slice(0, -1));
this.historyIndex = -1;
this.cursorPosition = 0;
try {
sessionStorage.setItem('__python_wasm_repl.history', JSON.stringify(this.history));
} catch(e) {
}
}
resolve(value); resolve(value);
}; };
}); });
@ -327,8 +546,6 @@
const stopButton = document.getElementById("stop"); const stopButton = document.getElementById("stop");
const clearButton = document.getElementById("clear"); const clearButton = document.getElementById("clear");
const codeBox = document.getElementById("codebox");
window.onload = () => { window.onload = () => {
const terminal = new WasmTerminal(); const terminal = new WasmTerminal();
terminal.open(document.getElementById("terminal")); terminal.open(document.getElementById("terminal"));
@ -362,8 +579,9 @@
runButton.addEventListener("click", (e) => { runButton.addEventListener("click", (e) => {
terminal.clear(); terminal.clear();
terminal.reset(); // reset the history
programRunning(true); programRunning(true);
const code = codeBox.value; const code = editor.getValue();
pythonWorkerManager.run({ pythonWorkerManager.run({
args: ["main.py"], args: ["main.py"],
files: { "main.py": code }, files: { "main.py": code },
@ -372,10 +590,28 @@
replButton.addEventListener("click", (e) => { replButton.addEventListener("click", (e) => {
terminal.clear(); terminal.clear();
terminal.reset(); // reset the history
const REPL = `
class WASMREPLKeyboardInterrupt(KeyboardInterrupt):
pass
import sys
import code
import builtins
def _interrupt_aware_input(prompt=''):
line = builtins.input(prompt)
if line.strip() == "${_magic_ctrlc_string}":
raise KeyboardInterrupt()
return line
cprt = 'Type "help", "copyright", "credits" or "license" for more information.'
banner = f'Python {sys.version} on {sys.platform}\\n{cprt}'
code.interact(banner=banner, readfunc=_interrupt_aware_input, exitmsg='')
`;
programRunning(true); programRunning(true);
// Need to use "-i -" to force interactive mode. pythonWorkerManager.run({ args: ["-c", REPL], files: {} });
// Looks like isatty always returns false in emscripten
pythonWorkerManager.run({ args: ["-i", "-"], files: {} });
}); });
stopButton.addEventListener("click", (e) => { stopButton.addEventListener("click", (e) => {
@ -395,6 +631,7 @@
const finishedCallback = () => { const finishedCallback = () => {
programRunning(false); programRunning(false);
pythonWorkerManager.reset();
}; };
const pythonWorkerManager = new WorkerManager( const pythonWorkerManager = new WorkerManager(
@ -404,23 +641,27 @@
finishedCallback, finishedCallback,
); );
}; };
var editor;
document.addEventListener("DOMContentLoaded", () => {
editor = ace.edit("editor");
editor.session.setMode("ace/mode/python");
});
</script> </script>
</head> </head>
<body> <body>
<div id="repldemo">
<h1>Simple REPL for Python WASM</h1> <h1>Simple REPL for Python WASM</h1>
<textarea id="codebox" cols="108" rows="16"> <div id="editor">print('Welcome to WASM!')</div>
print('Welcome to WASM!')
</textarea
>
<div class="button-container"> <div class="button-container">
<button id="run" disabled>Run</button> <button id="run" disabled>Run code</button>
<button id="repl" disabled>Start REPL</button> <button id="repl" disabled>Start REPL</button>
<button id="stop" disabled>Stop</button> <button id="stop" disabled>Stop</button>
<button id="clear" disabled>Clear</button> <button id="clear" disabled>Clear</button>
</div> </div>
<div id="terminal"></div> <div id="terminal"></div>
<div id="info"> <div id="info">
The simple REPL provides a limited Python experience in the browser. The simple REPL provides a limited Python experience in the
browser.
<a <a
href="https://github.com/python/cpython/blob/main/Tools/wasm/README.md" href="https://github.com/python/cpython/blob/main/Tools/wasm/README.md"
> >
@ -429,5 +670,34 @@ print('Welcome to WASM!')
contains a list of known limitations and issues. Networking, contains a list of known limitations and issues. Networking,
subprocesses, and threading are not available. subprocesses, and threading are not available.
</div> </div>
</div>
<div id="buffererror" class="error" style="display: none">
<p>
<code>SharedArrayBuffer</code>, which is required for this demo,
is not available in your browser environment. One common cause
of this failure is loading <code>index.html</code> directly in
your browser instead of using <code>server.py</code> as
described in
<a
href="https://github.com/python/cpython/blob/main/Tools/wasm/README.md#the-web-example"
>
Tools/wasm/README.md
</a>.
</p>
<p>
For more details about security requirements for
<code>SharedArrayBuffer</code>, see
<a
href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements"
>this MDN page</a
>.
</p>
<script>
if (typeof SharedArrayBuffer === 'undefined') {
document.getElementById('repldemo').style.display = 'none';
document.getElementById('buffererror').style.display = 'block';
}
</script>
</div>
</body> </body>
</html> </html>

2
configure generated vendored
View file

@ -9603,7 +9603,7 @@ fi
as_fn_append LDFLAGS_NODIST " -sWASM_BIGINT" as_fn_append LDFLAGS_NODIST " -sWASM_BIGINT"
as_fn_append LINKFORSHARED " -sFORCE_FILESYSTEM -lidbfs.js -lnodefs.js -lproxyfs.js -lworkerfs.js" as_fn_append LINKFORSHARED " -sFORCE_FILESYSTEM -lidbfs.js -lnodefs.js -lproxyfs.js -lworkerfs.js"
as_fn_append LINKFORSHARED " -sEXPORTED_RUNTIME_METHODS=FS,callMain,ENV" as_fn_append LINKFORSHARED " -sEXPORTED_RUNTIME_METHODS=FS,callMain,ENV,HEAPU32"
as_fn_append LINKFORSHARED " -sEXPORTED_FUNCTIONS=_main,_Py_Version,__PyRuntime,__PyEM_EMSCRIPTEN_COUNT_ARGS_OFFSET,_PyGILState_GetThisThreadState,__Py_DumpTraceback" as_fn_append LINKFORSHARED " -sEXPORTED_FUNCTIONS=_main,_Py_Version,__PyRuntime,__PyEM_EMSCRIPTEN_COUNT_ARGS_OFFSET,_PyGILState_GetThisThreadState,__Py_DumpTraceback"
as_fn_append LINKFORSHARED " -sSTACK_SIZE=5MB" as_fn_append LINKFORSHARED " -sSTACK_SIZE=5MB"
as_fn_append LINKFORSHARED " -sTEXTDECODER=2" as_fn_append LINKFORSHARED " -sTEXTDECODER=2"

View file

@ -2335,7 +2335,7 @@ AS_CASE([$ac_sys_system],
dnl Include file system support dnl Include file system support
AS_VAR_APPEND([LINKFORSHARED], [" -sFORCE_FILESYSTEM -lidbfs.js -lnodefs.js -lproxyfs.js -lworkerfs.js"]) AS_VAR_APPEND([LINKFORSHARED], [" -sFORCE_FILESYSTEM -lidbfs.js -lnodefs.js -lproxyfs.js -lworkerfs.js"])
AS_VAR_APPEND([LINKFORSHARED], [" -sEXPORTED_RUNTIME_METHODS=FS,callMain,ENV"]) AS_VAR_APPEND([LINKFORSHARED], [" -sEXPORTED_RUNTIME_METHODS=FS,callMain,ENV,HEAPU32"])
AS_VAR_APPEND([LINKFORSHARED], [" -sEXPORTED_FUNCTIONS=_main,_Py_Version,__PyRuntime,__PyEM_EMSCRIPTEN_COUNT_ARGS_OFFSET,_PyGILState_GetThisThreadState,__Py_DumpTraceback"]) AS_VAR_APPEND([LINKFORSHARED], [" -sEXPORTED_FUNCTIONS=_main,_Py_Version,__PyRuntime,__PyEM_EMSCRIPTEN_COUNT_ARGS_OFFSET,_PyGILState_GetThisThreadState,__Py_DumpTraceback"])
AS_VAR_APPEND([LINKFORSHARED], [" -sSTACK_SIZE=5MB"]) AS_VAR_APPEND([LINKFORSHARED], [" -sSTACK_SIZE=5MB"])
dnl Avoid bugs in JS fallback string decoding path dnl Avoid bugs in JS fallback string decoding path