@@ -143,31 +143,37 @@ export function flushStreams(pyodide) {
143143// integer, prints just that argument and exits 1. We used to dump the whole
144144// WASM traceback for every sys.exit(), which meant a clean `sys.exit(0)` — what
145145// `python -m pytest` does on every green run — looked like a crash.
146+ //
147+ // `kind` names WHICH of the three this was, because the interactive REPL has to
148+ // tell them apart and must not grow a second SystemExit parser to do it: an
149+ // `exit()` typed at a `>>>` ends the session with this code, a Ctrl-C returns to
150+ // a fresh prompt, and anything else is one statement's error in a session that
151+ // carries on. A script needs none of that distinction and ignores the field.
146152export function terminationFromError ( e ) {
147153 const msg = ( e && e . message ) || String ( e ) ;
148154 const last = msg . trimEnd ( ) . split ( "\n" ) . pop ( ) . trim ( ) ;
149155 // Ctrl-C. CPython prints the traceback like any other exception and exits
150156 // 128+SIGINT, and a shell that reports 130 is how a script author tells an
151157 // interrupted run from a failed one.
152158 if ( ( e && e . type === "KeyboardInterrupt" ) || / ^ K e y b o a r d I n t e r r u p t \b / . test ( last ) ) {
153- return { code : 130 , report : msg } ;
159+ return { kind : "interrupt" , code : 130 , report : msg } ;
154160 }
155161 const isExit = ( e && e . type === "SystemExit" ) || / ^ S y s t e m E x i t \b / . test ( last ) ;
156- if ( ! isExit ) return { code : 1 , report : msg } ;
162+ if ( ! isExit ) return { kind : "error" , code : 1 , report : msg } ;
157163 const m = / ^ S y s t e m E x i t : \s * ( [ \s \S ] * ) $ / . exec ( last ) ;
158164 const value = m ? m [ 1 ] . trim ( ) : "" ;
159- if ( ! value || value === "None" ) return { code : 0 , report : "" } ; // bare sys.exit()
160- if ( / ^ - ? \d + $ / . test ( value ) ) return { code : Number ( value ) | 0 , report : "" } ;
165+ if ( ! value || value === "None" ) return { kind : "exit" , code : 0 , report : "" } ; // bare sys.exit()
166+ if ( / ^ - ? \d + $ / . test ( value ) ) return { kind : "exit" , code : Number ( value ) | 0 , report : "" } ;
161167 // Bools ARE ints in Python: sys.exit(True) exits 1 and sys.exit(False) exits
162168 // 0, printing nothing either way. The traceback spells both as
163169 // "SystemExit: True"/"False" — indistinguishable from sys.exit("True"), so
164170 // the message is a lossy channel and this picks the far likelier reading.
165171 // `sys.exit(not ok)` is a common idiom; exiting with the literal string
166172 // "False" is not. It is also the reading that keeps a *successful* run
167173 // reporting success, which the string reading got backwards.
168- if ( value === "True" ) return { code : 1 , report : "" } ;
169- if ( value === "False" ) return { code : 0 , report : "" } ;
170- return { code : 1 , report : value } ; // sys.exit("message")
174+ if ( value === "True" ) return { kind : "exit" , code : 1 , report : "" } ;
175+ if ( value === "False" ) return { kind : "exit" , code : 0 , report : "" } ;
176+ return { kind : "exit" , code : 1 , report : value } ; // sys.exit("message")
171177}
172178
173179// Undo one consequence of our own Node masquerade: it switches Python's HTTP off.
@@ -911,8 +917,13 @@ export function dataPackagesFor(source) {
911917 *
912918 * Returns null at end of input — but a part-typed line without its newline is
913919 * still a line, as it is in CPython when you type something and press Ctrl-D.
920+ *
921+ * With an `echo` sink this also becomes the line discipline a canonical-mode
922+ * terminal would provide, because nothing under it does: it shows each character
923+ * as it arrives and lets DEL rub one out. Only the interactive REPL passes one —
924+ * see repl(), which also decides when echoing is right at all.
914925 */
915- export function makeLineReader ( read ) {
926+ export function makeLineReader ( read , echo ) {
916927 const readChunk = read || ( ( ) => ( globalThis . __ocReadStdin ? globalThis . __ocReadStdin ( ) : null ) ) ;
917928 let buf = "" ;
918929 return ( ) => {
@@ -932,7 +943,33 @@ export function makeLineReader(read) {
932943 }
933944 return null ;
934945 }
935- buf += chunk ;
946+ if ( ! echo ) {
947+ buf += chunk ;
948+ continue ;
949+ }
950+ for ( const ch of chunk ) {
951+ if ( ch === "\x7f" || ch === "\b" ) {
952+ // Erase within the line being typed only: a DEL at a fresh prompt must
953+ // rub out neither the prompt that invited it nor a line already queued
954+ // behind this one (a paste arrives as one chunk of several lines).
955+ if ( buf && ! buf . endsWith ( "\n" ) ) {
956+ buf = buf . slice ( 0 , - 1 ) ;
957+ echo ( "\b \b" ) ;
958+ }
959+ continue ;
960+ }
961+ buf += ch ;
962+ // Control characters go on the screen as ^X, which is what a terminal
963+ // with ECHOCTL does and is here for a sharper reason than fidelity: an
964+ // arrow key arrives as the three bytes ESC [ A, and echoing those
965+ // verbatim would move the terminal's cursor into output printed earlier
966+ // and type the rest of the line there. Newline and tab are excluded
967+ // because their effect on the screen is the point of them.
968+ const code = ch . charCodeAt ( 0 ) ;
969+ echo ( code < 0x20 && ch !== "\n" && ch !== "\r" && ch !== "\t"
970+ ? "^" + String . fromCharCode ( code + 64 )
971+ : ch ) ;
972+ }
936973 }
937974 } ;
938975}
@@ -2278,8 +2315,10 @@ json.dumps({
22782315 const console_ = pyodide . globals . get ( "_vv_console" ) ;
22792316
22802317 let more = false ;
2318+ let ended = false ;
22812319 const prompt = ( ) => process . stdout . write ( more ? "... " : ">>> " ) ;
22822320 const finish = ( codeVal ) => {
2321+ ended = true ;
22832322 try {
22842323 console_ . destroy && console_ . destroy ( ) ;
22852324 } catch {
@@ -2292,6 +2331,25 @@ json.dumps({
22922331 // InteractiveConsole.push returns True when more input is needed.
22932332 more = ! ! withInterruptsSync ( ( ) => console_ . push ( line ) ) ;
22942333 } catch ( e ) {
2334+ const t = terminationFromError ( e ) ;
2335+ // exit(), quit(), sys.exit(): CPython's InteractiveInterpreter.runcode
2336+ // re-raises SystemExit instead of reporting it (Lib/code.py) precisely
2337+ // so that the loop driving it can end the session — and this is that
2338+ // loop. Treated as a printable error, as it used to be, `exit()`
2339+ // answered with a traceback and a fresh prompt: a crash report for
2340+ // the one line every user of a REPL knows how to type.
2341+ //
2342+ // The code and the message are the same reading a script's top-level
2343+ // SystemExit gets, from the same parser: exit()/exit(0) leave 0,
2344+ // exit(3) leaves 3, exit("bye") prints bye and leaves 1.
2345+ if ( t . kind === "exit" ) {
2346+ flushStreams ( pyodide ) ;
2347+ if ( t . report ) {
2348+ process . stderr . write ( t . report . endsWith ( "\n" ) ? t . report : t . report + "\n" ) ;
2349+ }
2350+ finish ( t . code ) ;
2351+ return ;
2352+ }
22952353 // Ctrl-C during a statement. CPython prints the name alone and gives
22962354 // a fresh top-level prompt, abandoning any half-typed block — the
22972355 // session survives, which is the whole point of interrupting it.
@@ -2322,7 +2380,29 @@ json.dumps({
23222380 //
23232381 // The cost is that this process's event loop does not turn while the
23242382 // prompt waits, which is what CPython does at a `>>>` too.
2325- const readLine = makeLineReader ( ) ;
2383+ //
2384+ // And it echoes, because in this system nobody else can. On a real
2385+ // terminal the keystroke a person types is shown by the line discipline
2386+ // in the kernel's tty layer, and there is none here: process.stdin
2387+ // records setRawMode and nothing more (packages/runtime/index.js), and
2388+ // the shell hands its foreground child raw keystrokes without echoing
2389+ // them (coreutils.js) exactly so the child can drive its own display.
2390+ // So the reader of a line is the thing that must show it, which is why
2391+ // the shell echoes at its own prompt and why this loop must at a `>>>`.
2392+ //
2393+ // Not one layer lower, in installStdin: that would echo every read the
2394+ // interpreter makes, including `getpass()`, and Python could not turn it
2395+ // off — Emscripten's tty answers tcgetattr with ECHO already clear and
2396+ // accepts a tcsetattr it then ignores, so getpass believes echo is off,
2397+ // prints no warning, and the password would go up on the screen.
2398+ //
2399+ // To stderr, not stdout, because echo is a write to the terminal and not
2400+ // part of what this process produces: `cat script.py | python > out` must
2401+ // not find the typed source in out. And only with a terminal attached
2402+ // (VV_TTY, the shell's own marker — the same one `ls` colours on), so a
2403+ // captured or scripted run stays byte-for-byte what it was.
2404+ const echo = process . env . VV_TTY === "1" ? ( text ) => process . stderr . write ( text ) : null ;
2405+ const readLine = makeLineReader ( null , echo ) ;
23262406
23272407 // One statement per macrotask rather than a `while` loop, so timers and
23282408 // the process's own exit path get their turn between lines.
@@ -2334,6 +2414,9 @@ json.dumps({
23342414 return ;
23352415 }
23362416 feed ( line ) ;
2417+ // An `exit()` on that line ended the session. Reading again would park
2418+ // this process on a stdin nobody is going to type into.
2419+ if ( ended ) return ;
23372420 setTimeout ( step , 0 ) ;
23382421 } ;
23392422 prompt ( ) ;
0 commit comments