Skip to content

Commit

Permalink
PHP: Output stdout and stderr to files instead of streams
Browse files Browse the repository at this point in the history
Replaces output streams with temporary files to fix the following test case:

```
  ● PHP  › should echo bytes
    expect(
        php.run('<?php echo chr(1).chr(0).chr(1).chr(0).chr(2);').stdout
    ).toBe('\x01\x00\x01\x00\x02');

    Expected: "1 0 1 0 2"
    Received: "1 1 2"
```

Turns out Emscripten has special semantics for null bytes in stdout and doesn't pass them to JavaScript handlers. This commit ditches stdout and stderr as a transport, and moves to  temporary files instead. This way the null bytes output by PHP make it unchanged to the browser.

One other change to enable that is upgrading the PHPOutput.stdout datatype from string to ArrayBuffer to make sure the bytes are passed around correctly.

Solves WordPress#80
  • Loading branch information
adamziel committed Nov 25, 2022
1 parent 053b662 commit affcf3e
Show file tree
Hide file tree
Showing 7 changed files with 176 additions and 53 deletions.
4 changes: 2 additions & 2 deletions build/php.js

Large diffs are not rendered by default.

Binary file modified build/php.wasm
Binary file not shown.
5 changes: 0 additions & 5 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,6 @@ async function collectBuiltWordPress() {
}

async function collectBuiltPHP() {
glob.sync(`${outputDir}/php.js`).map((filePath) =>
fs.rmSync(filePath, { force: true })
);
fs.rmSync(`${outputDir}/php.wasm`, { force: true });

const phpOutputDir = path.join(__dirname, 'build-php');
await asyncPipe(gulp.src([`${phpOutputDir}/*`]).pipe(gulp.dest(outputDir)));
}
Expand Down
98 changes: 98 additions & 0 deletions src/php-wasm/__tests__/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as phpLoaderModule from '../../../build/php.node.js';
import { startPHP } from '../php';

const { TextEncoder, TextDecoder } = require('util');
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;

describe('PHP – boot', () => {
it('should boot', async () => {
const php = await startPHP(phpLoaderModule, 'NODE');
expect(php.run('<?php echo "1";')).toEqual({
stdout: '1',
stderr: [''],
exitCode: 0,
});
});
});

describe('PHP ', () => {
let php;
beforeAll(async () => {
php = await startPHP(phpLoaderModule, 'NODE');
});

it('should output strings (1)', async () => {
expect(php.run('<?php echo "Hello world!";')).toEqual({
stdout: 'Hello world!',
stderr: [''],
exitCode: 0,
});
});
it('should output strings (2) ', async () => {
expect(php.run('<?php echo "Hello world!\nI am PHP";')).toEqual({
stdout: 'Hello world!\nI am PHP',
stderr: [''],
exitCode: 0,
});
});
it('should output bytes ', async () => {
const results = php.run(
'<?php echo chr(1).chr(0).chr(1).chr(0).chr(2);'
);
expect({
...results,
stdout: bytesStringToHumanReadable(results.stdout),
}).toEqual({
stdout: bytesStringToHumanReadable('\x01\x00\x01\x00\x02'),
stderr: [''],
exitCode: 0,
});
});
it('should output strings when .run() is called twice', async () => {
expect(php.run('<?php echo "Hello world!";')).toEqual({
stdout: 'Hello world!',
stderr: [''],
exitCode: 0,
});

expect(php.run('<?php echo "Ehlo world!";')).toEqual({
stdout: 'Ehlo world!',
stderr: [''],
exitCode: 0,
});
});
it('should output data to stderr', async () => {
const code = `<?php
$stdErr = fopen('php://stderr', 'w');
fwrite($stdErr, "Hello from stderr!");
`;
expect(php.run(code)).toEqual({
stdout: '',
stderr: ['Hello from stderr!'],
exitCode: 0,
});
});
it('should output data to stderr in the shutdown handler', async () => {
const code = `<?php
$stdErr = fopen('php://stderr', 'w');
$errors = [];
register_shutdown_function(function() use($stdErr){
fwrite($stdErr, "Hello from stderr!");
});
`;
expect(php.run(code)).toEqual({
stdout: '',
stderr: ['Hello from stderr!'],
exitCode: 0,
});
});
});

function bytesStringToHumanReadable(bytes) {
return bytes
.split('')
.map((byte) => byte.charCodeAt(0))
.join(' ');
}
// echo chr(1).chr(0).chr(0).chr(5);
34 changes: 7 additions & 27 deletions src/php-wasm/php.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,6 @@ const NUM = 'number';

export type JavascriptRuntime = 'NODE' | 'WEB' | 'WEBWORKER';

/**
* @internal
*/
interface Streams {
stdout: string[];
stderr: string[];
}

/**
* Initializes the PHP runtime with the given arguments and data dependencies.
*
Expand Down Expand Up @@ -142,18 +134,12 @@ export async function startPHP(
resolvePhpReady = resolve;
});

const streams: Streams = {
stdout: [],
stderr: [],
};
const loadPHPRuntime = phpLoaderModule.default;
const PHPRuntime = loadPHPRuntime(runtime, {
onAbort(reason) {
console.error('WASM aborted: ');
console.error(reason);
},
print: (...chunks) => streams.stdout.push(...chunks),
printErr: (...chunks) => streams.stderr.push(...chunks),
...phpModuleArgs,
noInitialRun: true,
onRuntimeInitialized() {
Expand All @@ -178,7 +164,7 @@ export async function startPHP(

await depsReady;
await phpReady;
return new PHP(PHPRuntime, streams);
return new PHP(PHPRuntime);
}

/**
Expand All @@ -194,18 +180,15 @@ export async function startPHP(
*/
export class PHP {
#Runtime;
#streams;

/**
* Initializes a PHP runtime.
*
* @internal
* @param PHPRuntime - PHP Runtime as initialized by startPHP.
* @param streams - An object pointing to stdout and stderr streams, as initilized by startPHP.
*/
constructor(PHPRuntime: any, streams: Streams) {
constructor(PHPRuntime: any) {
this.#Runtime = PHPRuntime;
this.#streams = streams;

this.mkdirTree('/usr/local/etc');
// @TODO: make this customizable
Expand Down Expand Up @@ -256,13 +239,12 @@ session.save_path=/home/web_user
[STR],
[`?>${code}`]
);
const response = {
this.#refresh();
return {
exitCode,
stdout: this.#streams.stdout.join('\n'),
stderr: this.#streams.stderr,
stdout: this.readFileAsBuffer('/tmp/stdout'),
stderr: this.readFileAsText('/tmp/stderr').split('\n'),
};
this.#refresh();
return response;
}

/**
Expand All @@ -276,8 +258,6 @@ session.save_path=/home/web_user
*/
#refresh() {
this.#Runtime.ccall('phpwasm_refresh', NUM, [], []);
this.#streams.stdout = [];
this.#streams.stderr = [];
}

/**
Expand Down Expand Up @@ -489,7 +469,7 @@ export interface PHPOutput {
exitCode: number;

/** Stdout data */
stdout: string;
stdout: ArrayBuffer;

/** Stderr lines */
stderr: string[];
Expand Down
84 changes: 66 additions & 18 deletions src/php-wasm/wasm/build-assets/php_wasm.c
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
/**
* Public API for php.wasm.
*
*
* This file abstracts the entire PHP API with the minimal set
* of functions required to run PHP code in JavaScript.
*/

#include "sapi/embed/php_embed.h"
#include <emscripten.h>
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

#include "zend_globals_macros.h"
#include "zend_exceptions.h"
Expand All @@ -24,17 +27,49 @@
#include "pdo_sqlite.c"

/*
* Function: phpwasm_flush
* Function: redirect_stream_to_file
* ----------------------------
* Flush any buffered stdout and stderr contents.
* Redirects writes from a given stream to a file with a speciied path.
* Think of it as a the ">" operator in "echo foo > bar.txt" bash command.
*
* stream: The stream to redirect, e.g. stdout or stderr.
*
* path: The path to the file to redirect to, e.g. "/tmp/stdout".
*
* returns: The exit code: 0 on success, -1 on failure.
*/
void phpwasm_flush()
int redirect_stream_to_file(FILE *stream, char *file_path)
{
fflush(stdout);
fprintf(stdout, "\n");
int out = open(file_path, O_TRUNC | O_WRONLY | O_CREAT, 0600);
if (-1 == out)
{
return -1;
}

fflush(stderr);
fprintf(stderr, "\n");
int replacement_stream = dup(fileno(stream));
if (-1 == dup2(out, fileno(stream)))
{
perror("cannot redirect stdout");
return -1;
}

return replacement_stream;
}

/*
* Function: restore_stream_handler
* ----------------------------
* Restores a stream handler to its original state from before the redirect_stream_to_file
* function was called.
*
* stream: The stream to restore, e.g. stdout or stderr.
*
* replacement_stream: The replacement stream returned by the redirect_stream_to_file function.
*/
void restore_stream_handler(FILE *original_stream, int replacement_stream)
{
dup2(replacement_stream, fileno(original_stream));
close(replacement_stream);
}

/*
Expand All @@ -44,17 +79,24 @@ void phpwasm_flush()
*
* code: The PHP code to run. Must include the `<?php` opener.
*
* returns: the exit code. 0 means success, 1 means the code died, 2 means an error.
* returns: The exit code. 0 means success, 1 means the code died, 2 means an error.
*/
int EMSCRIPTEN_KEEPALIVE phpwasm_run(char *code)
{
int retVal = 255; // Unknown error.

int stdout_replacement = redirect_stream_to_file(stdout, "/tmp/stdout");
int stderr_replacement = redirect_stream_to_file(stderr, "/tmp/stderr");
if (stdout_replacement == -1 || stderr_replacement == -1)
{
return retVal;
}

zend_try
{
retVal = zend_eval_string(code, NULL, "php-wasm run script");

if(EG(exception))
if (EG(exception))
{
zend_exception_error(EG(exception), E_ERROR);
retVal = 2;
Expand All @@ -67,10 +109,15 @@ int EMSCRIPTEN_KEEPALIVE phpwasm_run(char *code)

zend_end_try();

phpwasm_flush();
fflush(stdout);
fflush(stderr);

restore_stream_handler(stdout, stdout_replacement);
restore_stream_handler(stderr, stderr_replacement);

return retVal;
}

/*
* Function: phpwasm_destroy_context
* ----------------------------
Expand Down Expand Up @@ -118,7 +165,8 @@ int EMSCRIPTEN_KEEPALIVE phpwasm_refresh()
* Frees the memory after a zval allocated to store the uploaded
* variable name.
*/
static void free_filename(zval *el) {
static void free_filename(zval *el)
{
// Uncommenting this code causes a runtime error in the browser:
// @TODO evaluate whether keeping it commented leads to a memory leak
// and how to fix it if it does.
Expand All @@ -130,11 +178,11 @@ static void free_filename(zval *el) {
* Function: phpwasm_init_uploaded_files_hash
* ----------------------------
* Allocates an internal HashTable to keep track of the legitimate uploads.
*
*
* Functions like `is_uploaded_file` or `move_uploaded_file` don't work with
* $_FILES entries that are not in an internal hash table. It's a security feature.
* This function allocates that internal hash table.
*
*
* @see PHP.initUploadedFilesHash in the JavaScript package for more details.
*/
void EMSCRIPTEN_KEEPALIVE phpwasm_init_uploaded_files_hash()
Expand All @@ -151,7 +199,7 @@ void EMSCRIPTEN_KEEPALIVE phpwasm_init_uploaded_files_hash()
* Function: phpwasm_register_uploaded_file
* ----------------------------
* Registers an uploaded file in the internal hash table.
*
*
* @see PHP.initUploadedFilesHash in the JavaScript package for more details.
*/
void EMSCRIPTEN_KEEPALIVE phpwasm_register_uploaded_file(char *tmp_path_char)
Expand All @@ -164,7 +212,7 @@ void EMSCRIPTEN_KEEPALIVE phpwasm_register_uploaded_file(char *tmp_path_char)
* Function: phpwasm_destroy_uploaded_files_hash
* ----------------------------
* Destroys the internal hash table to free the memory.
*
*
* @see PHP.initUploadedFilesHash in the JavaScript package for more details.
*/
void EMSCRIPTEN_KEEPALIVE phpwasm_destroy_uploaded_files_hash()
Expand All @@ -180,7 +228,7 @@ void EMSCRIPTEN_KEEPALIVE phpwasm_destroy_uploaded_files_hash()
* ----------------------------
* Required by the VRZNO module.
* Why? I'm not sure.
*
*
* @see https://github.com/seanmorris/vrzno
*/
int EMSCRIPTEN_KEEPALIVE exec_callback(zend_function *fptr)
Expand All @@ -197,7 +245,7 @@ int EMSCRIPTEN_KEEPALIVE exec_callback(zend_function *fptr)
* ----------------------------
* Required by the VRZNO module.
* Why? I'm not sure.
*
*
* @see https://github.com/seanmorris/vrzno
*/
int EMSCRIPTEN_KEEPALIVE del_callback(zend_function *fptr)
Expand Down
Loading

0 comments on commit affcf3e

Please sign in to comment.