Skip to content

Commit 6857de6

Browse files
hoodmanevstinner
andauthored
gh-146416: Emscripten: Improve standard stream handling in node_entry.mjs (#146417)
Co-authored-by: Victor Stinner <vstinner@python.org>
1 parent 6420847 commit 6857de6

File tree

5 files changed

+252
-3
lines changed

5 files changed

+252
-3
lines changed

Platforms/emscripten/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,10 @@ def configure_emscripten_python(context, working_dir):
518518
EMSCRIPTEN_DIR / "node_entry.mjs", working_dir / "node_entry.mjs"
519519
)
520520

521+
shutil.copy(
522+
EMSCRIPTEN_DIR / "streams.mjs", working_dir / "streams.mjs"
523+
)
524+
521525
node_entry = working_dir / "node_entry.mjs"
522526
exec_script = working_dir / "python.sh"
523527
exec_script.write_text(

Platforms/emscripten/node_entry.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import EmscriptenModule from "./python.mjs";
22
import fs from "node:fs";
3+
import { initializeStreams } from "./streams.mjs";
34

45
if (process?.versions?.node) {
56
const nodeVersion = Number(process.versions.node.split(".", 1)[0]);
@@ -39,6 +40,9 @@ const settings = {
3940
Object.assign(Module.ENV, process.env);
4041
delete Module.ENV.PATH;
4142
},
43+
onRuntimeInitialized() {
44+
initializeStreams(Module.FS);
45+
},
4246
// Ensure that sys.executable, sys._base_executable, etc point to python.sh
4347
// not to this file. To properly handle symlinks, python.sh needs to compute
4448
// its own path.
@@ -49,7 +53,7 @@ const settings = {
4953

5054
try {
5155
await EmscriptenModule(settings);
52-
} catch(e) {
56+
} catch (e) {
5357
// Show JavaScript exception and traceback
5458
console.warn(e);
5559
// Show Python exception and traceback

Platforms/emscripten/streams.mjs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/**
2+
* This is a pared down version of
3+
* https://github.com/pyodide/pyodide/blob/main/src/js/streams.ts
4+
*
5+
* It replaces the standard streams devices that Emscripten provides with our
6+
* own better ones. It fixes the following deficiencies:
7+
*
8+
* 1. The emscripten std streams always have isatty set to true. These set
9+
* isatty to match the value for the stdin/stdout/stderr that node sees.
10+
* 2. The emscripten std streams don't support the ttygetwinsize ioctl. If
11+
* isatty() returns true, then these do, and it returns the actual window
12+
* size as the OS reports it to Node.
13+
* 3. The emscripten std streams introduce an extra layer of buffering which has
14+
* to be flushed with fsync().
15+
* 4. The emscripten std streams are slow and complex because they go through a
16+
* character-based handler layer. This is particularly awkward because both
17+
* sides of this character based layer deal with buffers and so we need
18+
* complex adaptors, buffering, etc on both sides. Removing this
19+
* character-based middle layer makes everything better.
20+
* https://github.com/emscripten-core/emscripten/blob/1aa7fb531f11e11e7ae49b75a24e1a8fe6fa4a7d/src/lib/libtty.js?plain=1#L104-L114
21+
*
22+
* Ideally some version of this should go upstream to Emscripten since it is not
23+
* in any way specific to Python. But I (Hood) haven't gotten around to it yet.
24+
*/
25+
26+
import * as tty from "node:tty";
27+
import * as fs from "node:fs";
28+
29+
let FS;
30+
const DEVOPS = {};
31+
const DEVS = {};
32+
33+
function isErrnoError(e) {
34+
return e && typeof e === "object" && "errno" in e;
35+
}
36+
37+
const waitBuffer = new Int32Array(
38+
new WebAssembly.Memory({ shared: true, initial: 1, maximum: 1 }).buffer,
39+
);
40+
function syncSleep(timeout) {
41+
try {
42+
Atomics.wait(waitBuffer, 0, 0, timeout);
43+
return true;
44+
} catch (_) {
45+
return false;
46+
}
47+
}
48+
49+
/**
50+
* Calls the callback and handle node EAGAIN errors.
51+
*/
52+
function handleEAGAIN(cb) {
53+
while (true) {
54+
try {
55+
return cb();
56+
} catch (e) {
57+
if (e && e.code === "EAGAIN") {
58+
// Presumably this means we're in node and tried to read from/write to
59+
// an O_NONBLOCK file descriptor. Synchronously sleep for 10ms then try
60+
// again. In case for some reason we fail to sleep, propagate the error
61+
// (it will turn into an EOFError).
62+
if (syncSleep(10)) {
63+
continue;
64+
}
65+
}
66+
throw e;
67+
}
68+
}
69+
}
70+
71+
function readWriteHelper(stream, cb, method) {
72+
let nbytes;
73+
try {
74+
nbytes = handleEAGAIN(cb);
75+
} catch (e) {
76+
if (e && e.code && Module.ERRNO_CODES[e.code]) {
77+
throw new FS.ErrnoError(Module.ERRNO_CODES[e.code]);
78+
}
79+
if (isErrnoError(e)) {
80+
// the handler set an errno, propagate it
81+
throw e;
82+
}
83+
console.error("Error thrown in read:");
84+
console.error(e);
85+
throw new FS.ErrnoError(Module.ERRNO_CODES.EIO);
86+
}
87+
if (nbytes === undefined) {
88+
// Prevent an infinite loop caused by incorrect code that doesn't return a
89+
// value.
90+
// Maybe we should set nbytes = buffer.length here instead?
91+
console.warn(
92+
`${method} returned undefined; a correct implementation must return a number`,
93+
);
94+
throw new FS.ErrnoError(Module.ERRNO_CODES.EIO);
95+
}
96+
if (nbytes !== 0) {
97+
stream.node.timestamp = Date.now();
98+
}
99+
return nbytes;
100+
}
101+
102+
function asUint8Array(arg) {
103+
if (ArrayBuffer.isView(arg)) {
104+
return new Uint8Array(arg.buffer, arg.byteOffset, arg.byteLength);
105+
} else {
106+
return new Uint8Array(arg);
107+
}
108+
}
109+
110+
const prepareBuffer = (buffer, offset, length) =>
111+
asUint8Array(buffer).subarray(offset, offset + length);
112+
113+
const TTY_OPS = {
114+
ioctl_tiocgwinsz(tty) {
115+
return tty.devops.ioctl_tiocgwinsz?.();
116+
},
117+
};
118+
119+
const stream_ops = {
120+
open: function (stream) {
121+
const devops = DEVOPS[stream.node.rdev];
122+
if (!devops) {
123+
throw new FS.ErrnoError(Module.ERRNO_CODES.ENODEV);
124+
}
125+
stream.devops = devops;
126+
stream.tty = stream.devops.isatty ? { ops: TTY_OPS, devops } : undefined;
127+
stream.seekable = false;
128+
},
129+
close: function (stream) {
130+
// flush any pending line data
131+
stream.stream_ops.fsync(stream);
132+
},
133+
fsync: function (stream) {
134+
const ops = stream.devops;
135+
if (ops.fsync) {
136+
ops.fsync();
137+
}
138+
},
139+
read: function (stream, buffer, offset, length, pos /* ignored */) {
140+
buffer = prepareBuffer(buffer, offset, length);
141+
return readWriteHelper(stream, () => stream.devops.read(buffer), "read");
142+
},
143+
write: function (stream, buffer, offset, length, pos /* ignored */) {
144+
buffer = prepareBuffer(buffer, offset, length);
145+
return readWriteHelper(stream, () => stream.devops.write(buffer), "write");
146+
},
147+
};
148+
149+
function nodeFsync(fd) {
150+
try {
151+
fs.fsyncSync(fd);
152+
} catch (e) {
153+
if (e?.code === "EINVAL") {
154+
return;
155+
}
156+
// On Mac, calling fsync when not isatty returns ENOTSUP
157+
// On Windows, stdin/stdout/stderr may be closed, returning EBADF or EPERM
158+
if (
159+
e?.code === "ENOTSUP" || e?.code === "EBADF" || e?.code === "EPERM"
160+
) {
161+
return;
162+
}
163+
164+
throw e;
165+
}
166+
}
167+
168+
class NodeReader {
169+
constructor(nodeStream) {
170+
this.nodeStream = nodeStream;
171+
this.isatty = tty.isatty(nodeStream.fd);
172+
}
173+
174+
read(buffer) {
175+
try {
176+
return fs.readSync(this.nodeStream.fd, buffer);
177+
} catch (e) {
178+
// Platform differences: on Windows, reading EOF throws an exception,
179+
// but on other OSes, reading EOF returns 0. Uniformize behavior by
180+
// catching the EOF exception and returning 0.
181+
if (e.toString().includes("EOF")) {
182+
return 0;
183+
}
184+
throw e;
185+
}
186+
}
187+
188+
fsync() {
189+
nodeFsync(this.nodeStream.fd);
190+
}
191+
}
192+
193+
class NodeWriter {
194+
constructor(nodeStream) {
195+
this.nodeStream = nodeStream;
196+
this.isatty = tty.isatty(nodeStream.fd);
197+
}
198+
199+
write(buffer) {
200+
return fs.writeSync(this.nodeStream.fd, buffer);
201+
}
202+
203+
fsync() {
204+
nodeFsync(this.nodeStream.fd);
205+
}
206+
207+
ioctl_tiocgwinsz() {
208+
return [this.nodeStream.rows ?? 24, this.nodeStream.columns ?? 80];
209+
}
210+
}
211+
212+
export function initializeStreams(fsarg) {
213+
FS = fsarg;
214+
const major = FS.createDevice.major++;
215+
DEVS.stdin = FS.makedev(major, 0);
216+
DEVS.stdout = FS.makedev(major, 1);
217+
DEVS.stderr = FS.makedev(major, 2);
218+
219+
FS.registerDevice(DEVS.stdin, stream_ops);
220+
FS.registerDevice(DEVS.stdout, stream_ops);
221+
FS.registerDevice(DEVS.stderr, stream_ops);
222+
223+
FS.unlink("/dev/stdin");
224+
FS.unlink("/dev/stdout");
225+
FS.unlink("/dev/stderr");
226+
227+
FS.mkdev("/dev/stdin", DEVS.stdin);
228+
FS.mkdev("/dev/stdout", DEVS.stdout);
229+
FS.mkdev("/dev/stderr", DEVS.stderr);
230+
231+
DEVOPS[DEVS.stdin] = new NodeReader(process.stdin);
232+
DEVOPS[DEVS.stdout] = new NodeWriter(process.stdout);
233+
DEVOPS[DEVS.stderr] = new NodeWriter(process.stderr);
234+
235+
FS.closeStream(0 /* stdin */);
236+
FS.closeStream(1 /* stdout */);
237+
FS.closeStream(2 /* stderr */);
238+
FS.open("/dev/stdin", 0 /* O_RDONLY */);
239+
FS.open("/dev/stdout", 1 /* O_WRONLY */);
240+
FS.open("/dev/stderr", 1 /* O_WRONLY */);
241+
}

configure

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

configure.ac

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2365,7 +2365,7 @@ AS_CASE([$ac_sys_system],
23652365
23662366
dnl Include file system support
23672367
AS_VAR_APPEND([LINKFORSHARED], [" -sFORCE_FILESYSTEM -lidbfs.js -lnodefs.js -lproxyfs.js -lworkerfs.js"])
2368-
AS_VAR_APPEND([LINKFORSHARED], [" -sEXPORTED_RUNTIME_METHODS=FS,callMain,ENV,HEAPU32,TTY"])
2368+
AS_VAR_APPEND([LINKFORSHARED], [" -sEXPORTED_RUNTIME_METHODS=FS,callMain,ENV,HEAPU32,TTY,ERRNO_CODES"])
23692369
AS_VAR_APPEND([LINKFORSHARED], [" -sEXPORTED_FUNCTIONS=_main,_Py_Version,__PyRuntime,_PyGILState_GetThisThreadState,__Py_DumpTraceback,__PyEM_EMSCRIPTEN_TRAMPOLINE_OFFSET"])
23702370
AS_VAR_APPEND([LINKFORSHARED], [" -sSTACK_SIZE=5MB"])
23712371
dnl Avoid bugs in JS fallback string decoding path

0 commit comments

Comments
 (0)