Repository navigation
Conversation
8cb071f to
d446c00
Compare
|
I found the root cause. The above blueprint will run successfully if I comment the if (!isPrimary) {
const pathsToShareBetweenPhpInstances = [
'/tmp',
requestHandler.documentRoot,
'/internal/shared',
'/internal/symlinks',
];
const pathsToProxy = pathsToShareBetweenPhpInstances.filter(
(path) => !isPathToSharedFS(php, path)
);
// TODO: Document that this shift is a breaking change.
// Proxy the filesystem for all secondary PHP instances to
// the primary one.
proxyFileSystem(
await requestHandler.getPrimaryPhp(),
php,
pathsToProxy
);
}This means something is happening to the If I add these lines in the + if( path !== '/internal/shared' ){
// @ts-ignore
replica[__private__symbol].FS.mount(
// @ts-ignore
replica[__private__symbol].PROXYFS,
{
root: path,
// @ts-ignore
fs: sourceOfTruth[__private__symbol].FS,
},
path
);
+ }It runs correctly. I wonder if mounting a file used by PHP internals like the intl extension will corrupt it. |
|
@adamziel I created a new directory named I tagged you because I wanted to know if |
|
Maybe |
|
Hm, not mounting that file worries me. First, what corrupts that file? Could it affect other files and corrupt other data? Second, the system is wired for sharing files between php instances and I worry about the consequences of not sharing them. The reason we have these mounts is to share a set of files between all the php instances - they're created once in the primary env and then every other php instance sources them from that primary php's filesystem. If we stop mounting, what would happen to icu.dat in non-primary php instances or after the php env is rotated? |
|
@adamziel Here’s my current understanding of why we were still hitting the Intl issue and how it relates to filesystem sharing. Each PHP instance is initialized via Later on, when Even though I can’t say exactly what happens internally inside To fix this, my proposal is to isolate native extensions Each PHP instance would load its extensions from this directory during startup, and that directory would never be proxied or remounted. This keeps ICU and other native extensions stable and instance-safe. In contrast, What do you think ? |
|
@mho22 Were you able to verify your theory by doing a checksum of the file with and without the mounting? |
|
@akirk The checksum is always the same. I was wrong about data corruption. But I think I found out what was the issue : This is the export async function proxyFileSystem(
sourceOfTruth: PHP,
replica: PHP,
paths: string[]
) {
const __private__symbol = Object.getOwnPropertySymbols(sourceOfTruth)[0];
for (const path of paths) {
if (!replica.fileExists(path)) {
replica.mkdir(path);
}
if (!sourceOfTruth.fileExists(path)) {
sourceOfTruth.mkdir(path);
}
+ async function checksumSHA256(data: Uint8Array): Promise<string>
+ {
+ const hashBuffer = await crypto.subtle.digest( 'SHA-256', data as BufferSource );
+
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
+ }
+ if( replica.fileExists( '/internal/shared' ) ) console.log( 'BEFORE', await checksumSHA256( replica.readFileAsBuffer( '/internal/shared/icudt74l.dat' ) ) );
// @ts-ignore
replica[__private__symbol].FS.mount(
// @ts-ignore
replica[__private__symbol].PROXYFS,
{
root: path,
// @ts-ignore
fs: sourceOfTruth[__private__symbol].FS,
},
path
);
+ if( replica.fileExists( '/internal/shared' ) ) console.log( 'AFTER ', await checksumSHA256( replica.readFileAsBuffer( '/internal/shared/icudt74l.dat' ) ) );
}
}These are the logs when the And these are the logs when the There is no // @ts-ignore
const FS1 = replica[__private__symbol].FS;
const node1 = FS1.lookupPath('/internal/shared/icudt74l.dat').node;
console.log('is MEMFS?', node1.mount.type === FS1.filesystems.MEMFS);
console.log('is PROXYFS?', node1.mount.type === FS1.filesystems.PROXYFS);I'll get these logs : So at one point in time, we reach data from |
|
This Blueprint: Gave me some more info: It seems like something fundamental with file access is broken in PROXYFS. I'd rather not work around that but pinpoint the exact problem. I've built a bunch of tests to see how PROXYFS deals with different FS operations and found that this test fails with errno 8: beforeEach(async () => {
phpA = new PHP(await loadNodeRuntime(phpVersion));
phpB = new PHP(await loadNodeRuntime(phpVersion));
// Mount /internal/shared from phpA to phpB using PROXYFS
proxyFileSystem(phpA, phpB, [sharedPath]);
});
it('should open a file in read-write mode (O_RDWR)', () => {
const testFile = `${sharedPath}/rdwr-test.txt`;
phpA.writeFile(testFile, 'initial content');
const fsB = getFS(phpB);
const stream = fsB.open(testFile, O_RDWR);
try {
// Read from the file
const readBuffer = new Uint8Array(8);
fsB.read(stream, readBuffer, 0, 8, 0);
expect(new TextDecoder().decode(readBuffer)).toBe('initial ');
// Write to the file
const writeContent = 'new';
const writeBuffer = new TextEncoder().encode(writeContent);
fsB.write(stream, writeBuffer, 0, writeBuffer.length, 8); // Write after "initial "
fsB.close(stream);
expect(phpA.readFileAsText(testFile)).toBe('initial newnt'); // 'content' becomes 'newnt'
} finally {
fsB.close(stream);
}
});There could be more problems in there. |
|
This one also seems to be failing: it('should change file access and modification times through PROXYFS', () => {
const testFile = `${sharedPath}/utime-test.txt`;
phpA.writeFile(testFile, 'utime test');
const fsB = getFS(phpB);
const oldStat = fsB.stat(testFile);
// Set new access and modification times (e.g., 1 day later)
const newAtime = (oldStat.atime.getTime() / 1000) + 86400;
const newMtime = (oldStat.mtime.getTime() / 1000) + 86400;
fsB.utime(testFile, newAtime, newMtime);
const newStat = fsB.stat(testFile);
// Emscripten's FS.utime expects seconds, but stat returns milliseconds.
// Compare with a small tolerance due to potential floating point inaccuracies.
expect(newStat.atime.getTime() / 1000).toBeCloseTo(newAtime);
expect(newStat.mtime.getTime() / 1000).toBeCloseTo(newMtime);
}); |
|
Back to square one :(
|
|
Some additional info: I've instrumented all of the stream ops functions in PROXYFS as follows: stream_ops: {
open(stream) {
if (stream.path.endsWith('.dat')) {
console.log('stream open', stream);
}
This gave me a stream open log line right before the error. I've set a breakpoint on it and followed the execution step by step. Here's the trace in case we ever need it again:
Some interesting source links: A possible source of the error: /* data not found */
if(U_SUCCESS(*pErrorCode)) {
if(U_SUCCESS(subErrorCode)) {
/* file not found */
*pErrorCode=U_FILE_ACCESS_ERROR;
} else {
/* entry point not found or rejected */
*pErrorCode=subErrorCode;
}
}@mho22 is that helpful and puts you in a good place to continue the search? |
|
@adamziel Yes, thank you! I am on it. Let's find out why it returns |
|
@adamziel @akirk I think I fixed the issue. Everything was related to an unexistent Thanks to Adam's first check, I was able to find my way to the different files starting with
This gave me the basis to debug the way back after PROXYFS has opened the file. And first stop : I found out the following part of the file was called [ since there are 4 #elif MAP_IMPLEMENTATION==MAP_POSIX
U_CFUNC UBool
uprv_mapFile(UDataMemory *pData, const char *path, UErrorCode *status) {
int fd;
int length;
struct stat mystat;
void *data;
if (U_FAILURE(*status)) {
return false;
}
UDataMemory_init(pData); /* Clear the output struct. */
/* determine the length of the file */
if(stat(path, &mystat)!=0 || mystat.st_size<=0) {
return false;
}
length=mystat.st_size;
/* open the file */
fd=open(path, O_RDONLY);
if(fd==-1) {
return false;
}
/* get a view of the mapping */
#if U_PLATFORM != U_PF_HPUX
data=mmap(nullptr, length, PROT_READ, MAP_SHARED, fd, 0);
#else
data=mmap(nullptr, length, PROT_READ, MAP_PRIVATE, fd, 0);
#endif
close(fd); /* no longer needed */
if(data==MAP_FAILED) {
// Possibly check the errno value for ENOMEM, and report U_MEMORY_ALLOCATION_ERROR?
return false;
}
pData->map = (char *)data + length;
pData->pHeader=(const DataHeader *)data;
pData->mapAddr = data;
pData->length = length;
#if U_PLATFORM == U_PF_IPHONE || U_PLATFORM == U_PF_ANDROID
// Apparently supported from Android 23 and higher:
// https://github.com/ggml-org/llama.cpp/pull/3631
// Checking for the flag itself is safer than checking for __ANDROID_API__.
# ifdef POSIX_MADV_RANDOM
posix_madvise(data, length, POSIX_MADV_RANDOM);
# endif
#endif
return true;
}
/* get a view of the mapping */
#if U_PLATFORM != U_PF_HPUX
data=mmap(nullptr, length, PROT_READ, MAP_SHARED, fd, 0);
#else
data=mmap(nullptr, length, PROT_READ, MAP_PRIVATE, fd, 0);
#endifAnd yup! PROXYFS does not have a I had to find a way to shims that First of all, how to modify that file. Well, by copying my own file into the
# Build ICU libraries
RUN set -euxo pipefail; \
wget https://github.com/unicode-org/icu/releases/download/release-74-2/icu4c-74_2-src.tgz; \
tar -xvf icu4c-74_2-src.tgz; \
rm -rf /root/icu/source/data/; \
rm icu4c-74_2-src.tgz; \
wget https://github.com/unicode-org/icu/releases/download/release-74-2/icu4c-74_2-data.zip; \
unzip icu4c-74_2-data.zip -d /root/icu/source; \
rm icu4c-74_2-data.zip; \
mkdir -p /root/native; \
cd /root/native; \
/root/icu/source/runConfigureICU Linux; \
make -j1; \
- make install; \
+ make install;
+
+ COPY ./shared/intl/umapfile.cpp /root/icu/source/common/umapfile.cpp
+
+ RUN cd /root/icu/source; \
- cd /root/icu/source; \
mkdir -p /root; \
source /root/emsdk/emsdk_env.sh; \
export CFLAGS="-fPIC -D__x86_64__"; \
export CXXFLAGS="-fPIC -D__x86_64__"; \
emconfigure ./configure \
--host=i386-unknown-freebsd \
--prefix=/root \
--with-cross-build=/root/native \
--with-data-packaging=archive \
--disable-extras \
--enable-static \
--disable-shared; \
EMCC_FLAGS="-D__x86_64__" emmake make -j1; \
EMCC_FLAGS="-D__x86_64__" emmake make install;Then, run #include <emscripten/emscripten.h>
emscripten_log(EM_LOG_CONSOLE, "ICU uprv_mapFile path=%s size=%d", path, length);These logs helped me understand what was missing. I finally replaced the - /* get a view of the mapping */
- #if U_PLATFORM != U_PF_HPUX
- data=mmap(nullptr, length, PROT_READ, MAP_SHARED, fd, 0);
- #else
- data=mmap(nullptr, length, PROT_READ, MAP_PRIVATE, fd, 0);
- #endif
- close(fd); /* no longer needed */
- if(data==MAP_FAILED) {
- // Possibly check the errno value for ENOMEM, and report U_MEMORY_ALLOCATION_ERROR?
- return false;
- }
+ // --- allocate ---
+ data = malloc(length);
+ if (!data) {
+ close(fd);
+ *status = U_MEMORY_ALLOCATION_ERROR;
+ return false;
+ }
+
+ emscripten_log(EM_LOG_CONSOLE, "allocate done");
+
+ // --- read ---
+ r = pread(fd, data, length, 0);
+ close(fd);
+
+ if (r != length) {
+ free(data);
+ return false;
+ }
+
+ emscripten_log(EM_LOG_CONSOLE, "ICU map OK: %s (%d bytes)", path, length);And it worked! No more So I decided to use a clean #include "umapfile.h"
+ #if defined(__EMSCRIPTEN__)
+ # undef MAP_IMPLEMENTATION
+ # define MAP_IMPLEMENTATION MAP_STDIO
+ #endif
/* memory-mapping base definitions ------------------------------------------ */It still worked. Nice. Next :
- COPY ./shared/intl/umapfile.cpp /root/icu/source/common/umapfile.cpp
+ RUN /root/replace.sh 's/#include \"umapfile.h\"/#include "umapfile.h"\n\n#if defined(__EMSCRIPTEN__)\n# undef MAP_IMPLEMENTATION\n# define MAP_IMPLEMENTATION MAP_STDIO\n#endif\n\n/' /root/icu/source/common/umapfile.cpp;And that's it. 🎉 I will now update the pull request with it. I should also find a way to reproduce the issue in a test in |
011b48a to
a87eec4
Compare
|
@mho22 Great debugging! Can we leave the CPP code unchanged and just implement |
|
Here's some prior art on overriding Emscripten core FS methods: |
|
The last commit implements a version of However, I don't like the following part of the function but it solves the 7.3 and 7.2 crash : if (phpRuntime.phpVersion.major === 7 && phpRuntime.phpVersion.minor <= 3) {
const path = phpRuntime.FS.getPath(stream.node);
if (!path.endsWith(".dat")) {
const fs = stream.node.mount.opts.fs;
const stat = fs.fstat(stream.nfd);
length = stat.size >>> 0;
}
}This has to be more generic. Edit: it still doesn't work for Web JSPI... |
5ca517e to
5aa40fd
Compare
The mmap implementation for PROXYFS now correctly handles file reading for older PHP versions (7.2-7.4). Key changes: 1. Get actual file size from the proxied filesystem using fstat() for non-.dat files, as older PHP versions may pass incorrect length values 2. Read file contents using a loop with subarray view into the heap, matching how MEMFS and the PR #3073 implementation work 3. Fix symbol usage bug - get symbols from both replica and sourceOfTruth instances separately to ensure correct property access when instances come from different module builds (ESM vs CJS) 4. Enable Intl extension tests for PHP 7.2, 7.3, and 7.4
## Summary Removes support for PHP 7.2 and 7.3. Why? * Those version are effectively not functional anyway. PHP 7.2 seems to crash after a minute or two and PHP 7.3 is really slow. It somehow fell through the cracks and the E2E test suite didn't catch that. * No one reported that problem, which tells me not many people relied on those PHP versions. * [WordPress will drop support for 7.2 and 7.2 in April](https://make.wordpress.org/core/2026/01/09/dropping-support-for-php-7-1-2), they're used by 1.81% and 2.04% of sites respectively * 7.2 and 7.3 are burdensome to maintain. Just recently, they don't seem to work [with the PROXYFS mmap implementation](#3073). That could be resolved with more work, but any fix won't be relevant anymore after April and spending time there delays resolving the `intl` extension crash for everyone else. ## Implementation details When a Blueprint runs that references PHP 7.2 or 7.3, Playground will still accept that Blueprint, but it will automatically upgrades the PHP version to 7.4 with a warning message. This ensures existing Blueprints continue working while encouraging users to update their configurations. Here's a high-level overview of the changes: - **Version definitions**: Removed PHP 7.2 and 7.3 from supported versions lists - **Module loaders**: Removed import cases for 7.2 and 7.3 in node and web loaders - **Blueprint compilation**: Added automatic upgrade logic from 7.2/7.3 to 7.4 - **Schema validation**: Updated blueprint schema and regenerated validator - **Tests**: Updated version checks and removed version-specific workarounds - **Documentation**: Updated all docs to reflect new minimum version ## Test plan - [x] All blueprint tests pass - [x] Automatic upgrade from 7.2 to 7.4 works correctly - [x] Automatic upgrade from 7.3 to 7.4 works correctly - [x] PHP 7.4 and newer versions work as before - [x] Blueprint schema validator updated successfully
## What it does Adds `mmap` support to PROXYFS so the Intl extension works when `/internal/shared` (which contains ICU data files) is accessed through a proxied filesystem. This PR is based on [@mho22](https://github.com/mho22)'s excellent work in [#3073](#3073). ## Rationale PROXYFS proxies filesystem operations between PHP instances, enabling file sharing. The Intl extension depends on ICU, which uses `mmap` to load its data file (`icudt74l.dat`). Without `mmap` support in PROXYFS, ICU initialization fails when accessing the data file through a proxied mount. This showed up when trying to use `Collator` or `NumberFormatter` in a PHP instance where `/internal/shared` was mounted via PROXYFS—the classes would fail to instantiate even though the extension was loaded. ## Implementation Added `ensureProxyFSHasMmapSupport()` that patches PROXYFS at runtime: **`mmap` implementation:** - Allocates memory using the runtime's `malloc` - Reads the entire file into that memory via PROXYFS's existing `read` operation - Returns a pointer to the allocated buffer **Special handling for PHP 7.4:** - PHP 7.4 sometimes passes incorrect `length` values to `mmap` for non-`.dat` files - Solution: query the actual file size from the proxied filesystem using `fstat` **`msync` implementation:** - Writes changes back to the file if mapping was `MAP_SHARED` - Skips write for `MAP_PRIVATE` (changes stay in memory only) The patch is applied automatically when calling `proxyFileSystem()`, so existing code gets mmap support without changes. ## Testing instructions Run the new test suite: ```bash npm test -- proxyfs-mmap.spec.ts ``` Tests verify: - Basic file operations through PROXYFS (read, binary files, large files, concurrent access) - Intl classes (`Collator`, `NumberFormatter`) work through PROXYFS - All tests run across PHP 7.4–8.5 E2E tests verify Intl works in blueprints: ```bash npm run test:e2e -- blueprints.spec.ts -g "Intl" ``` Co-authored-by: Yannick Decat <yannick@capsules.codes>
|
merged via #3126 |

Motivation for the change, related issues
Based on this issue.
Related to this function.
It looks like the ICU data is not found when running the below Blueprint. Even if the file is located in
/internal/sharedbefore running theplayground.run(...)inrun-php.ts.Implementation details
Intl classes should work when intl is enabledtest inplayground/website/playwright/e2e/blueprints.spec.tsBlueprint
{ "features": { "intl": true }, "steps": [ { "step": "runPHP", "code": "<?php \n\n$data = array(\n 'F' => 'Foo',\n 'Br' => 'Bar',\n 'Bz' => 'Bz',\n);\n\n$collator = new Collator('en_US');\n$collator->asort($data, Collator::SORT_STRING);\n\nthrow new Exception( json_encode($data, JSON_PRETTY_PRINT ) );" } ] }Should throw :
But throws :