Skip to content

Commit 1f2730b

Browse files
authored
[PHP] Support non-blocking read streams (#2339)
## Description This PR adds supports for reading from non-blocking streams (`stream_set_blocking($fp, true)`) by aligning the streaming logic with unix kernel: * `stream_set_blocking()` is now able to correctly mark the OS stream as non blocking via `fcntl()` * `fd_read()` either blocks or immediately returns based on stream type * `js_open_process()`, `proc_open()` et al. now work directly with kernel pipes. There's no more special event-based handling, polling, or special casing for pumping stdin data to the process. * PIPEFS no longer returns EWOULDBLOCK when the pipe is blocking, lacks data, and the other side is already closed * Removed special cases for polling processes, including `PHPWASM.child_proc_by_fd`. We rely on kernel pipes now. * Removed PHP 7.4 patch to treat blocking streams in a special way ### Motivation for this patch With this PR, we can use the Symfony Process component. It spawns a new process via proc_open(), marks its output stream as non-blocking, and immediately reads the initial output bytes if any are present. Before this change, Playground would block until the first output is produced, and, sometimes until the entire process finishes. This PR is a pre-requisite to #2281 ### Other changes This PR implements `usleep()` (via `-Wl,--wrap=usleep`). It turns out that it wasn't actually blocking the execution and a few unit tests relying on it only passed due to a buggy implementation of blocking streams. ### Follow-up work * Replace `read()` globally with something equivalent to `wasm_read()`. Right now we only do this `RUN /root/replace.sh 's/ret = read/ret = wasm_read/g' /root/php-src/main/streams/plain_wrapper.c` * Consider not overriding `php_pollfd_for` and removing `wasm_poll_socket()` – wrapping select/poll/read syscalls may be enough * We could remove more special casing, including socket polling inside `wasm_poll_socket`, the `js_popen_to_file` function, `awaitData` et al. ## Testing Instructions (or ideally a Blueprint) Tests are included so just confirm the CI is green.
1 parent f78ab07 commit 1f2730b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

84 files changed

+6898
-7707
lines changed

packages/php-wasm/compile/php/Dockerfile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,7 @@ RUN export ASYNCIFY_IMPORTS=$'[\n\
732732
"cli",\
733733
"close_stmt_and_copy_errors",\
734734
"close",\
735+
"__stdio_close",\
735736
"closeUnixFile",\
736737
"compile_file",\
737738
"createCollation",\
@@ -2060,7 +2061,7 @@ RUN set -euxo pipefail; \
20602061
source /root/emsdk/emsdk_env.sh; \
20612062
if [ "$WITH_JSPI" = "yes" ]; then \
20622063
# Both imports and exports are required for inter-module communication with wrapped methods, e.g., wasm_recv.
2063-
export ASYNCIFY_FLAGS=" -s ASYNCIFY=2 -sSUPPORT_LONGJMP=wasm -fwasm-exceptions -sJSPI_IMPORTS=js_open_process,js_waitpid,js_process_status,js_create_input_device,wasm_setsockopt,wasm_shutdown,wasm_close,wasm_recv -sJSPI_EXPORTS=wasm_sleep,wasm_read,emscripten_sleep,wasm_sapi_handle_request,wasm_sapi_request_shutdown,wasm_poll_socket,wrap_select,__wrap_select,select,php_pollfd_for,fflush,wasm_popen,wasm_read,wasm_php_exec,run_cli,wasm_recv -s EXTRA_EXPORTED_RUNTIME_METHODS=ccall,PROXYFS,wasmExports,_malloc "; \
2064+
export ASYNCIFY_FLAGS=" -s ASYNCIFY=2 -sSUPPORT_LONGJMP=wasm -fwasm-exceptions -sJSPI_IMPORTS=js_open_process,js_fd_read,js_waitpid,js_process_status,js_create_input_device,wasm_setsockopt,wasm_shutdown,wasm_close,wasm_recv,__syscall_fcntl64,js_flock,js_release_file_locks,js_waitpid,fd_close -sJSPI_EXPORTS=php_wasm_init,fd_close,wasm_sleep,wasm_read,emscripten_sleep,wasm_sapi_handle_request,wasm_sapi_request_shutdown,wasm_poll_socket,wrap_select,__wrap_select,select,php_pollfd_for,fflush,wasm_popen,wasm_read,wasm_php_exec,run_cli,wasm_recv -s EXTRA_EXPORTED_RUNTIME_METHODS=ccall,PROXYFS,wasmExports,_malloc "; \
20642065
echo '#define PLAYGROUND_JSPI 1' > /root/php_wasm_asyncify.h; \
20652066
else \
20662067
export ASYNCIFY_FLAGS=" -s ASYNCIFY=1 -s ASYNCIFY_IGNORE_INDIRECT=1 -s EXPORTED_RUNTIME_METHODS=ccall,PROXYFS,wasmExports $(cat /root/.emcc-php-asyncify-flags) "; \
@@ -2134,6 +2135,7 @@ RUN set -euxo pipefail; \
21342135
-o /build/output/php.js \
21352136
-s EXIT_RUNTIME=1 \
21362137
-Wl,--wrap=select \
2138+
-Wl,--wrap=usleep \
21372139
/root/lib/libphp.a \
21382140
/root/proc_open.c \
21392141
/root/php_wasm.c \
@@ -2194,6 +2196,9 @@ RUN set -euxo pipefail; \
21942196
/root/replace.sh "s/sock\.server\s*=\s*new WebSocketServer/if (Module['websocket']['serverDecorator']) {WebSocketServer = Module['websocket']['serverDecorator'](WebSocketServer);}sock.server = new WebSocketServer/g" /root/output/php.js; \
21952197
fi; \
21962198
fi; \
2199+
# PIPEFS: return 0 (success) instead of 6 (EWOULDBLOCK) when reached end of data
2200+
# and the other end of the pipe is closed.
2201+
/root/replace-across-lines.sh 's/if\s*\(\s*currentLength\s*==\s*0\s*\)\s*\{.{0,200}throw new FS.ErrnoError\(\s*6\s*\)/if(currentLength==0){if(pipe.refcnt<2){return 0;}throw new FS.ErrnoError(6)/sg' /root/output/php.js; \
21972202
# Add MSG_PEEK flag support in recvfrom
21982203
#
21992204
# Emscripten ignores the flags argument to ___syscall_recvfrom.

packages/php-wasm/compile/php/php7.4.patch

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,3 @@
1-
diff --git a/php-src/main/streams/plain_wrapper.c b/php-src/main/streams/plain_wrapper.c
2-
index 4d10e688b5..ca50261b8a 100644
3-
--- a/php-src/main/streams/plain_wrapper.c
4-
+++ b/php-src/main/streams/plain_wrapper.c
5-
@@ -408,6 +408,15 @@ static ssize_t php_stdiop_read(php_stream *stream, char *buf, size_t count)
6-
}
7-
#endif
8-
ret = read(data->fd, buf, PLAIN_WRAP_BUF_SIZE(count));
9-
+ if(
10-
+ (errno == EAGAIN || errno == EWOULDBLOCK) &&
11-
+ (stream->flags & O_NONBLOCK) == 0
12-
+ ) {
13-
+ /* This is a blocking read in C but not in Emscripten.
14-
+ Let's poll for data and try once again */
15-
+ php_pollfd_for(data->fd, POLLIN, NULL);
16-
+ ret = read(data->fd, buf, PLAIN_WRAP_BUF_SIZE(count));
17-
+ }
18-
19-
if (ret == (size_t)-1 && errno == EINTR) {
20-
/* Read was interrupted, retry once,
21-
22-
231
diff --git a/php-src/ext/standard/file.c b/php-src/ext/standard/file.c
242
--- a/php-src/ext/standard/file.c
253
+++ b/php-src/ext/standard/file.c

packages/php-wasm/compile/php/php_wasm.c

Lines changed: 115 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ unsigned int wasm_sleep(unsigned int time)
3535
return time;
3636
}
3737

38+
/**
39+
* Shims usleep(3) functionallity.
40+
*/
41+
EMSCRIPTEN_KEEPALIVE unsigned int __wrap_usleep(unsigned int time_microseconds)
42+
{
43+
// We don't have access to microsecond granularity in JavaScript so let's
44+
// settle for milliseconds.
45+
int time_milliseconds = time_microseconds / 1000;
46+
emscripten_sleep(time_milliseconds);
47+
return time_microseconds;
48+
}
49+
3850
extern int *wasm_setsockopt(int sockfd, int level, int optname, intptr_t optval, size_t optlen, int dummy);
3951

4052
static int redirect_stream_to_file(FILE *stream, char *file_path);
@@ -151,11 +163,16 @@ EM_JS(int, wasm_poll_socket, (php_socket_t socketd, int events, int timeout), {
151163
return returnCallback((wakeUp) => {
152164
const polls = [];
153165
/**
154-
* Check for socket-ness first. We don't clean up child_proc_by_fd yet and
155-
* sometimes get duplicate entries. isSocket is more reliable out of the two –
156-
* let's check for it first.
166+
* Special semantics for polling sockets.
167+
*
168+
* @TODO: Remove this code branch entirely and poll sockets using the second if/else
169+
* branch below. The only reason this wasn't done yet is that the original PR
170+
* was focusing on removing child process-related special casing and did not
171+
* want to increase the risk of breaking other code, especially around listening
172+
* network sockets.
157173
*/
158-
if (FS.isSocket(FS.getStream(socketd)?.node.mode)) {
174+
const stream = FS.getStream(socketd);
175+
if (FS.isSocket(stream?.node.mode)) {
159176
// This is, most likely, a websocket. Let's make sure.
160177
const sock = getSocketFromFD(socketd);
161178
if (!sock) {
@@ -208,14 +225,40 @@ EM_JS(int, wasm_poll_socket, (php_socket_t socketd, int events, int timeout), {
208225
lookingFor.add('POLLERR');
209226
}
210227
}
211-
} else if (socketd in PHPWASM.child_proc_by_fd) {
212-
// This is a child process-related socket.
213-
const procInfo = PHPWASM.child_proc_by_fd[socketd];
214-
if (procInfo.exited) {
215-
wakeUp(0);
216-
return;
217-
}
218-
polls.push(PHPWASM.awaitEvent(procInfo.stdout, 'data'));
228+
} else if (stream?.stream_ops?.poll) {
229+
// Poll the stream for data.
230+
// @TODO: Consider reusing the polling implementation in js_fd_read().
231+
let interrupted = false;
232+
async function poll() {
233+
try {
234+
// Inlined ___syscall_poll with added await support:
235+
while (true) {
236+
var mask = POLLNVAL;
237+
mask = SYSCALLS.DEFAULT_POLLMASK;
238+
if (stream.stream_ops?.poll) {
239+
mask = stream.stream_ops.poll(stream, -1);
240+
}
241+
242+
mask &= events | POLLERR | POLLHUP;
243+
if (mask) {
244+
return mask;
245+
}
246+
if (interrupted) {
247+
return ERRNO_CODES.ETIMEDOUT;
248+
}
249+
await new Promise(resolve => setTimeout(resolve, 10));
250+
}
251+
} catch (e) {
252+
if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e;
253+
return -e.errno;
254+
}
255+
}
256+
polls.push([
257+
poll(),
258+
() => {
259+
interrupted = true;
260+
}
261+
]);
219262
} else {
220263
setTimeout(function () {
221264
wakeUp(1);
@@ -271,22 +314,20 @@ EM_JS(int, wasm_poll_socket, (php_socket_t socketd, int events, int timeout), {
271314
* @see https://github.com/WordPress/wordpress-playground/issues/951
272315
* @see https://github.com/emscripten-core/emscripten/issues/13214
273316
*/
317+
EM_JS(__wasi_errno_t, js_fd_read, (__wasi_fd_t fd, const __wasi_iovec_t *iov, size_t iovcnt, __wasi_size_t *pnum), {
274318
#ifdef PLAYGROUND_JSPI
275-
EM_ASYNC_JS(__wasi_errno_t, js_fd_read, (__wasi_fd_t fd, const __wasi_iovec_t *iov, size_t iovcnt, __wasi_size_t *pnum), {
276319
const returnCallback = (resolver) => new Promise(resolver);
277320
#else
278-
EM_JS(__wasi_errno_t, js_fd_read, (__wasi_fd_t fd, const __wasi_iovec_t *iov, size_t iovcnt, __wasi_size_t *pnum), {
279321
const returnCallback = (resolver) => Asyncify.handleSleep(resolver);
280322
#endif
323+
const pollAsync = arguments[4] === undefined ? true : !!arguments[4];
281324
if (Asyncify?.State?.Normal === undefined || Asyncify?.state === Asyncify?.State?.Normal) {
282-
var returnCode;
283325
var stream;
284-
let num = 0;
285326
try
286327
{
287328
stream = SYSCALLS.getStreamFromFD(fd);
288-
const num = doReadv(stream, iov, iovcnt);
289-
HEAPU32[pnum >> 2] = num;
329+
// How many bytes did we read?
330+
HEAPU32[pnum >> 2] = doReadv(stream, iov, iovcnt);
290331
return 0;
291332
}
292333
catch (e)
@@ -296,26 +337,38 @@ EM_JS(__wasi_errno_t, js_fd_read, (__wasi_fd_t fd, const __wasi_iovec_t *iov, si
296337
{
297338
throw e;
298339
}
299-
// Only return synchronously if this isn't an asynchronous pipe.
300-
// Error code 6 indicates EWOULDBLOCK – this is our signal to wait.
301-
// We also need to distinguish between a process pipe and a file pipe, otherwise
302-
// reading from an empty file would block until the timeout.
303-
if (e.errno !== 6 || !(stream?.fd in PHPWASM.child_proc_by_fd))
304-
{
305-
// On failure, yield 0 bytes read to indicate EOF.
306-
HEAPU32[pnum >> 2] = 0;
307-
return returnCode
340+
341+
// Propagate all errors except ones indicating that the stream is waiting for
342+
// more data.
343+
if (e.errno !== ERRNO_CODES.EWOULDBLOCK && e.errno !== ERRNO_CODES.EAGAIN) {
344+
return e.errno;
345+
}
346+
347+
// If the stream is non-blocking, we can return immediately.
348+
const nonBlocking = stream.flags & PHPWASM.O_NONBLOCK;
349+
if (nonBlocking) {
350+
return e.errno;
308351
}
352+
353+
// Otherwise, fallthrough to polling.
309354
}
310355
}
311356

312-
// At this point we know we have to poll.
313-
// You might wonder why we duplicate the code here instead of always using
357+
// Allow the caller to disable polling.
358+
// @TODO: Check if we should even poll here, or is it up to the caller to decide
359+
// and call poll() on their own.
360+
if (false === pollAsync) {
361+
return ERRNO_CODES.EWOULDBLOCK;
362+
}
363+
364+
// At this point we're certain we need to poll.
365+
//
366+
// You might wonder why we duplicate the code here instead of just reusing
314367
// Asyncify.handleSleep(). The reason is performance. Most of the time,
315368
// the read operation will work synchronously and won't require yielding
316369
// back to JS. In these cases we don't want to pay the Asyncify overhead,
317370
// save the stack, yield back to JS, restore the stack etc.
318-
return returnCallback((wakeUp) => {
371+
return returnCallback(async (wakeUp) => {
319372
var retries = 0;
320373
var interval = 50;
321374
var timeout = 5000;
@@ -324,7 +377,7 @@ EM_JS(__wasi_errno_t, js_fd_read, (__wasi_fd_t fd, const __wasi_iovec_t *iov, si
324377
// to, say, block the entire PHPUnit test suite without any visible
325378
// feedback.
326379
var maxRetries = timeout / interval;
327-
function poll() {
380+
while(true) {
328381
var returnCode;
329382
var stream;
330383
let num;
@@ -343,28 +396,30 @@ EM_JS(__wasi_errno_t, js_fd_read, (__wasi_fd_t fd, const __wasi_iovec_t *iov, si
343396
returnCode = e.errno;
344397
}
345398

346-
const success = returnCode === 0;
347-
const failure = (
348-
++retries > maxRetries ||
349-
!(fd in PHPWASM.child_proc_by_fd) ||
350-
PHPWASM.child_proc_by_fd[fd]?.exited ||
351-
FS.isClosed(stream)
352-
);
399+
// read succeeded!
400+
if (returnCode === 0) {
401+
HEAPU32[pnum >> 2] = num;
402+
return wakeUp(0);
403+
}
353404

354-
if (success) {
405+
if (
406+
// Too many retries? That's an error, too!
407+
++retries > maxRetries ||
408+
// Stream closed? That's an error.
409+
!stream || FS.isClosed(stream) ||
410+
// Error different than EWOULDBLOCK – propagate it to the caller.
411+
returnCode !== ERRNO_CODES.EWOULDBLOCK ||
412+
// Broken pipe
413+
('pipe' in stream.node && stream.node.pipe.refcnt < 2)
414+
) {
355415
HEAPU32[pnum >> 2] = num;
356-
wakeUp(0);
357-
} else if (failure) {
358-
// On failure, yield 0 bytes read to indicate EOF.
359-
HEAPU32[pnum >> 2] = 0;
360-
// If the failure is due to a timeout, return 0 to indicate that we
361-
// reached EOF. Otherwise, propagate the error code.
362-
wakeUp(returnCode === 6 ? 0 : returnCode);
363-
} else {
364-
setTimeout(poll, interval);
416+
return wakeUp(returnCode);
365417
}
418+
419+
// It's a blocking stream and we Blocking stream with no data available yet.
420+
// Let's poll up to a timeout.
421+
await new Promise(resolve => setTimeout(resolve, interval));
366422
}
367-
poll();
368423
})
369424
});
370425
extern int __wasi_syscall_ret(__wasi_errno_t code);
@@ -447,27 +502,30 @@ EMSCRIPTEN_KEEPALIVE FILE *wasm_popen(const char *cmd, const char *mode)
447502
}
448503
else if (*mode == 'w')
449504
{
450-
int current_procopen_call_id = ++procopen_call_id;
451-
char *device_path = js_create_input_device(current_procopen_call_id);
452-
int stdin_childend = current_procopen_call_id;
453-
fp = fopen(device_path, mode);
454-
505+
php_file_descriptor_t stdin_pipe[2];
455506
php_file_descriptor_t stdout_pipe[2];
456507
php_file_descriptor_t stderr_pipe[2];
457-
if (0 != pipe(stdout_pipe) || 0 != pipe(stderr_pipe))
508+
if (0 != pipe(stdout_pipe) || 0 != pipe(stderr_pipe) || 0 != pipe(stdin_pipe))
458509
{
459510
php_error_docref(NULL, E_WARNING, "unable to create pipe %s", strerror(errno));
460511
errno = EINVAL;
461512
return 0;
462513
}
463514

515+
fp = fdopen(stdin_pipe[1], "w"); // or "w", depending on direction
516+
if (!fp) {
517+
php_error_docref(NULL, E_WARNING, "unable to create pipe %s", strerror(errno));
518+
errno = EINVAL;
519+
return 0;
520+
}
521+
464522
int *stdin = safe_emalloc(sizeof(int), 3, 0);
465523
int *stdout = safe_emalloc(sizeof(int), 3, 0);
466524
int *stderr = safe_emalloc(sizeof(int), 3, 0);
467525

468526
stdin[0] = 0;
469-
stdin[1] = stdin_childend;
470-
stdin[2] = (int) NULL;
527+
stdin[1] = stdin_pipe[0];
528+
stdin[2] = stdin_pipe[1];
471529

472530
stdout[0] = 1;
473531
stdout[1] = stdout_pipe[0];
@@ -483,7 +541,6 @@ EMSCRIPTEN_KEEPALIVE FILE *wasm_popen(const char *cmd, const char *mode)
483541
descv[1] = stdout;
484542
descv[2] = stderr;
485543

486-
487544
// the wasm way {{{
488545
js_open_process(
489546
cmd,

packages/php-wasm/compile/php/phpwasm-emscripten-library-file-locking-for-node.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const LibraryForFileLocking = {
5252
F_RDLCK: 0,
5353
F_WRLCK: 1,
5454
F_UNLCK: 2,
55+
5556
lockStateToFcntl: {
5657
shared: 0,
5758
exclusive: 1,
@@ -155,6 +156,7 @@ const LibraryForFileLocking = {
155156
SYSCALLS.varargs = varargs;
156157

157158
// These constants are replaced by Emscripten during the build process
159+
const emscripten_F_SETFL = Number('{{{cDefs.F_SETFL}}}');
158160
const emscripten_F_GETLK = Number('{{{cDefs.F_GETLK}}}');
159161
const emscripten_F_SETLK = Number('{{{cDefs.F_SETLK}}}');
160162
const emscripten_F_SETLKW = Number('{{{cDefs.F_SETLKW}}}');
@@ -577,6 +579,26 @@ const LibraryForFileLocking = {
577579
// because it is a known errno for a failed F_SETLKW command.
578580
return -ERRNO_CODES.EDEADLK;
579581
}
582+
case emscripten_F_SETFL: {
583+
/**
584+
* Overrides the core Emscripten implementation to reflect what
585+
* fcntl does in linux kernel. This implementation is still missing
586+
* a bunch of nuance, but, unlike the core Emscripten implementation,
587+
* it overrides the stream flags while preserving non-stream flags.
588+
*
589+
* @see fcntl.c:
590+
* https://github.com/torvalds/linux/blob/a79a588fc1761dc12a3064fc2f648ae66cea3c5a/fs/fcntl.c#L39
591+
*/
592+
const arg = varargs ? syscallGetVarargI() : 0;
593+
const stream = SYSCALLS.getStreamFromFD(fd);
594+
595+
// Update the stream flags
596+
stream.flags =
597+
(arg & PHPWASM.SETFL_MASK) |
598+
(stream.flags & ~PHPWASM.SETFL_MASK);
599+
600+
return 0;
601+
}
580602
default:
581603
return _builtin_fcntl64(fd, cmd, varargs);
582604
}

0 commit comments

Comments
 (0)