diff --git a/emrun.py b/emrun.py index bcc46ae881124..5cff1e05d04c9 100644 --- a/emrun.py +++ b/emrun.py @@ -193,6 +193,26 @@ def browser_loge(msg): last_message_time = tick() +def browser_raw_logi(msg): + """Prints a message to the browser stdout output stream, wihtout adding a newline. + """ + global last_message_time + msg = format_eol(msg) + browser_stdout_handle.write(msg) + browser_stdout_handle.flush() + last_message_time = tick() + + +def browser_raw_loge(msg): + """Prints a message to the browser stderr output stream, wihtout adding a newline. + """ + global last_message_time + msg = format_eol(msg) + browser_stderr_handle.write(msg) + browser_stderr_handle.flush() + last_message_time = tick() + + def unquote_u(source): """Unquotes a unicode string. (translates ascii-encoded utf string back to utf) @@ -669,6 +689,26 @@ def log_message(self, format, *args): # noqa: DC04 if 'favicon.ico' not in msg: sys.stderr.write(msg) + def do_GET(self): + if self.path == "/in" and emrun_options.interactive: + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.flush() + + while True: + ch = sys.stdin.read(1) + if not ch: + self.wfile.write(b"event: close\ndata: bye\n\n") + self.wfile.flush() + return + self.wfile.write(f"data: {ord(ch)}\n\n".encode()) + self.wfile.flush() + return + + super().do_GET() + def do_POST(self): # # noqa: DC04 global page_exit_code, have_received_messages @@ -722,23 +762,30 @@ def do_POST(self): # # noqa: DC04 return else: # The user page sent a message with POST. Parse the message and log it to stdout/stderr. - is_stdout = False - is_stderr = False seq_num = -1 # The html shell is expected to send messages of form ^out^(number)^(message) or ^err^(number)^(message). + + trim_index = 0 + log = browser_logi if data.startswith('^err^'): - is_stderr = True + trim_index = 5 + log = browser_loge elif data.startswith('^out^'): - is_stdout = True - if is_stderr or is_stdout: + trim_index = 5 + elif data.startswith('^rawerr^'): + trim_index = 8 + log = browser_raw_loge + elif data.startswith('^rawout^'): + trim_index = 8 + log = browser_raw_logi + if trim_index > 0: try: - i = data.index('^', 5) - seq_num = int(data[5:i]) + i = data.index('^', trim_index) + seq_num = int(data[trim_index:i]) data = data[i + 1:] except ValueError: pass - log = browser_loge if is_stderr else browser_logi self.server.handle_incoming_message(seq_num, log, data) self.send_response(200) @@ -1576,6 +1623,10 @@ def parse_args(args): parser.add_argument('cmdlineparams', nargs='*') + parser.add_argument('--interactive', dest='interactive', action='store_true', + help='If specified, emrun streams the terminal input to the client.' + 'Note that blocking reading is not supported on the client.') + # Support legacy argument names with `_` in them (but don't # advertize these in the --help message). for i, a in enumerate(args): diff --git a/src/emrun_postjs.js b/src/emrun_postjs.js index 97e1ad116be9e..675fa5a6b1d83 100644 --- a/src/emrun_postjs.js +++ b/src/emrun_postjs.js @@ -65,6 +65,57 @@ if (globalThis.window && (typeof ENVIRONMENT_IS_PTHREAD == 'undefined' || !ENVIR prevErr(text); }; + // Receive inputs from emrun and forward them to the TTY input. + var inputBuf = []; + var readableHandlers = []; + var inputClosed = false; + const POLLIN = 0x1; + const POLLRDNORM = 0x040; + function notifyReadableHandlers() { + while (readableHandlers.length > 0) { + const cb = readableHandlers.shift(); + if (cb) cb(POLLIN | POLLRDNORM); + } + readableHandlers = []; + } + TTY.stream_ops.poll = (stream, timeout, notifyCallback) => { + if (inputClosed || (inputBuf.length > 0)) return (POLLIN | POLLRDNORM); + if (notifyCallback) { + notifyCallback.registerCleanupFunc(() => { + const i = readableHandlers.indexOf(notifyCallback); + if (i !== -1) readableHandlers.splice(i, 1); + }); + readableHandlers.push(notifyCallback); + } + }; + const es = new EventSource("/in"); + es.addEventListener("close", e => { + inputClosed = true; + notifyReadableHandlers(); + }); + es.onopen = () => { + es.onmessage = (e) => { + inputBuf.push(Number(e.data)); + notifyReadableHandlers(); + } + TTY.default_tty_ops.get_char = (tty) => { + const res = inputBuf.shift(); + // convert "undefined" to "null" because the TTY implementation + // interprets "undefined" as EAGAIN when there is no other read data. + return res ? res : null; + }; + + // Forward the output without buffering and cooking + TTY.default_tty_ops.fsync(TTY); // flush buffered contents + TTY.default_tty_ops.put_char = (tty, val) => { + post('^rawout^'+(emrun_http_sequence_number++)+'^'+encodeURIComponent(UTF8ArrayToString([val]))); + } + TTY.default_tty1_ops.fsync(TTY); // flush buffered contents + TTY.default_tty1_ops.put_char = (tty, val) => { + post('^rawerr^'+(emrun_http_sequence_number++)+'^'+encodeURIComponent(UTF8ArrayToString([val]))); + } + } + // Notify emrun web server that this browser has successfully launched the // page. Note that we may need to wait for the server to be ready. var tryToSendPageload = () => { diff --git a/test/test_browser.py b/test/test_browser.py index 3a072c4221d4d..ec20cac5c9952 100644 --- a/test/test_browser.py +++ b/test/test_browser.py @@ -5659,6 +5659,7 @@ def test_program_arg_separator(self): def test_emrun(self): self.emcc('test_emrun.c', ['--emrun', '-o', 'test_emrun.html']) + self.emcc('test_interactive_emrun.c', ['--emrun', '-pthread', '-sPROXY_TO_PTHREAD', '-sEXIT_RUNTIME', '-o', 'test_interactive_emrun.html']) if not has_browser(): self.skipTest('need a browser') @@ -5697,6 +5698,7 @@ def test_emrun(self): ['--private_browsing', '--port', '6941'], ['--dump_out_directory', 'other dir/multiple', '--port', '6942'], ['--dump_out_directory=foo_bar', '--port', '6942'], + ['--interactive'], ]: args = args_base + args + [self.in_dir('test_emrun.html'), '--', '1', '2', '--3', 'escaped space', 'with_underscore'] print(shlex.join(args)) @@ -5721,6 +5723,12 @@ def test_emrun(self): self.assertContained('Testing char sequences: %20%21 ä', stdout) self.assertContained('hello, error stream!', stderr) + args = args_base + ['--interactive', self.in_dir('test_interactive_emrun.html')] + print(shlex.join(args)) + proc = self.run_process(args, check=False, input="hello") + self.assertEqual(proc.returncode, 100) + self.assertContained('hello', stdout) + class browser64(browser): def setUp(self): diff --git a/test/test_interactive_emrun.c b/test/test_interactive_emrun.c new file mode 100644 index 0000000000000..bd6af0b86b926 --- /dev/null +++ b/test/test_interactive_emrun.c @@ -0,0 +1,34 @@ +/* + * Copyright 2025 The Emscripten Authors. All rights reserved. + * Emscripten is available under two separate licenses, the MIT license and the + * University of Illinois/NCSA Open Source License. Both these licenses can be + * found in the LICENSE file. + */ + +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + fd_set readfds; + char buf[124]; + ssize_t n; + int nr = 0; + + while (nr < sizeof(buf)) { + FD_ZERO(&readfds); + FD_SET(STDIN_FILENO, &readfds); + assert(select(STDIN_FILENO + 1, &readfds, NULL, NULL, NULL) == 1); + assert(FD_ISSET(STDIN_FILENO, &readfds)); + n = read(STDIN_FILENO, &(buf[nr]), sizeof(buf) - nr); + assert(n >= 0); + if (n == 0) break; + nr += n; + } + + printf("%s\n", buf); + + exit(0); +}