Skip to content

Commit 905f8ba

Browse files
mho22claude
andcommitted
posix-kernel: serve mounted docroot via host path on Windows
An earlier iteration created an NTFS junction at <kernel-tempdir>/ wordpress so nginx + php-fpm could reach the user-supplied --mount via a /dev/shm/.../wordpress POSIX path. The junction is created without error on Windows Server 2025 / Node 24, but fs.statSync traversal through it inside the kernel returns ENOENT — PHP's is_dir / is_file / include all fail, display_errors=off silences them, the request comes back 200 with empty body. nginx's POSIX argv parser is the only thing that actually requires the kernel-shaped path. Once nginx is started, every request goes through fastcgi_pass, so nginx's `root` directive is only used to populate $document_root — which we override anyway via a hardcoded fastcgi_param DOCUMENT_ROOT. Stop relying on the junction: keep `root` kernel-shaped to satisfy nginx, and hand PHP-FPM the real host filesystem path through DOCUMENT_ROOT + SCRIPT_FILENAME. The kernel passes anything outside /dev/shm/ straight to Node fs.*, which handles Windows-native paths natively. Also unifies KernelLimitedPHPApi: documentRoot / cwd / translateVfsPathsInCode all use the host path now; the kernelRoot field and toKernel helper (introduced for macOS back-translation) are gone. Bundles a second Windows-CI fix that accumulated alongside: allocate a per-boot host port for php-fpm so fileParallelism doesn't EADDRINUSE on the hard-coded 9000. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2bd8b02 commit 905f8ba

7 files changed

Lines changed: 76 additions & 73 deletions

File tree

packages/playground/cli/src/posix-kernel/boot.ts

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
* Boot WordPress backed by wasm-posix-kernel.
33
*
44
* Spawns one Node `worker_thread` that owns the kernel; inside it,
5-
* php-fpm listens on the kernel's TCP loopback (127.0.0.1:9000) and
6-
* nginx listens on the user-chosen port (kernel TCP bridge to the host).
7-
* The returned `runtime` lets blueprint v1 spawn additional `php.wasm`
8-
* CLI processes against the same worker, capturing their stdout/stderr.
5+
* php-fpm listens on a per-boot host-reserved port and nginx listens
6+
* on the user-chosen port (both via the kernel's TCP bridge to the
7+
* host — see `kernel-worker.ts:startTcpListener`). The returned
8+
* `runtime` lets blueprint v1 spawn additional `php.wasm` CLI
9+
* processes against the same worker, capturing their stdout/stderr.
910
*/
1011

1112
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
@@ -17,6 +18,7 @@ import {
1718
type NodeKernelHost,
1819
type SpawnOptions,
1920
} from './host-bridge';
21+
import { reserveFreePort } from '../start-server';
2022

2123
import ROUTER_PHP from './router.php?raw';
2224
import NGINX_CONF_TEMPLATE from './configs/nginx.conf?raw';
@@ -51,7 +53,6 @@ export interface PosixKernelBootResult extends AsyncDisposable {
5153
[Symbol.asyncDispose](): Promise<void>;
5254
}
5355

54-
const FPM_LOOPBACK_PORT = 9000;
5556
const FPM_BOOT_GRACE_MS = 2_000;
5657
const NGINX_READY_TIMEOUT_MS = 15_000;
5758

@@ -79,17 +80,24 @@ export async function bootPosixKernelWordPress(
7980
const phpFpmBytes = readWasm(bridge.binaries.phpFpmWasm);
8081
const nginxBytes = readWasm(bridge.binaries.nginxWasm);
8182

83+
// Reserve a free host port for php-fpm. The kernel's TCP bridge
84+
// (`kernel-worker.ts:startTcpListener`) translates a kernel-side
85+
// `listen()` into a real `net.createServer().listen(port, "0.0.0.0")`
86+
// — so two concurrently-booted kernels can't share a hard-coded
87+
// 9000 without colliding with `EADDRINUSE`. The port is internal
88+
// (only nginx-in-the-kernel talks to it via fastcgi_pass).
89+
const fpmPort = await reserveFreePort();
90+
8291
// Materialize the FastCGI router + php-fpm config from inlined
8392
// `?raw` strings so the published CLI bundle is self-contained
8493
// (no neighbouring .php / .conf source files in dist/).
8594
writeFileSync(joinPaths(options.tempDirHostPath, 'router.php'), ROUTER_PHP);
86-
const routerScriptKernelPath = joinPaths(
87-
options.tempDirKernelPath,
88-
'router.php'
95+
const routerScriptHostPath = toPosixSeparators(
96+
joinPaths(options.tempDirHostPath, 'router.php')
8997
);
9098
writeFileSync(
9199
joinPaths(options.tempDirHostPath, 'php-fpm.conf'),
92-
PHP_FPM_CONF
100+
PHP_FPM_CONF.replaceAll('__FPM_PORT__', String(fpmPort))
93101
);
94102
const fpmConfKernelPath = joinPaths(
95103
options.tempDirKernelPath,
@@ -108,13 +116,16 @@ export async function bootPosixKernelWordPress(
108116
options.tempDirKernelPath,
109117
'first-request-pending'
110118
);
119+
const documentRootForCgi = toPosixSeparators(options.wordPressRootHostPath);
111120
const host = options.host ?? DEFAULT_HOST;
112121
const renderedNginxConfKernelPath = renderNginxConf({
113122
host,
114123
port: options.port,
124+
fpmPort,
115125
serverName: options.serverName ?? 'localhost',
116126
wordPressRootKernelPath: options.wordPressRootKernelPath,
117-
routerScriptKernelPath,
127+
documentRootForCgi,
128+
routerScriptHostPath,
118129
tempDirHostPath: options.tempDirHostPath,
119130
tempDirKernelPath: options.tempDirKernelPath,
120131
firstRequestMarkerKernelPath,
@@ -176,17 +187,16 @@ export async function bootPosixKernelWordPress(
176187
kernelHost,
177188
phpFpmBytes,
178189
fpmConfKernelPath,
179-
options.wordPressRootKernelPath
190+
documentRootForCgi
191+
);
192+
// FPM is kernel-internal (only nginx-in-the-kernel connects to
193+
// it); probe the kernel's loopback bridge, not the user-chosen
194+
// nginx bind host.
195+
await waitForLoopback(DEFAULT_HOST, fpmPort, FPM_BOOT_GRACE_MS).catch(
196+
() => {
197+
/* nginx will retry */
198+
}
180199
);
181-
// FPM is kernel-internal; probe the kernel's loopback bridge,
182-
// not the user-chosen nginx bind host.
183-
await waitForLoopback(
184-
DEFAULT_HOST,
185-
FPM_LOOPBACK_PORT,
186-
FPM_BOOT_GRACE_MS
187-
).catch(() => {
188-
/* nginx will retry */
189-
});
190200

191201
spawnNginx(
192202
kernelHost,
@@ -269,9 +279,11 @@ function concatBytes(chunks: Uint8Array[]): Uint8Array {
269279
function renderNginxConf(args: {
270280
host: string;
271281
port: number;
282+
fpmPort: number;
272283
serverName: string;
273284
wordPressRootKernelPath: string;
274-
routerScriptKernelPath: string;
285+
documentRootForCgi: string;
286+
routerScriptHostPath: string;
275287
tempDirHostPath: string;
276288
tempDirKernelPath: string;
277289
firstRequestMarkerKernelPath: string;
@@ -280,9 +292,11 @@ function renderNginxConf(args: {
280292
const rendered = args.template
281293
.replaceAll('__HOST__', args.host)
282294
.replaceAll('__PORT__', String(args.port))
295+
.replaceAll('__FPM_PORT__', String(args.fpmPort))
283296
.replaceAll('__SERVER_NAME__', args.serverName)
284297
.replaceAll('__WORDPRESS_ROOT__', args.wordPressRootKernelPath)
285-
.replaceAll('__ROUTER_SCRIPT__', args.routerScriptKernelPath)
298+
.replaceAll('__DOCUMENT_ROOT__', args.documentRootForCgi)
299+
.replaceAll('__ROUTER_SCRIPT__', args.routerScriptHostPath)
286300
.replaceAll('__TEMP_DIR__', args.tempDirKernelPath)
287301
.replaceAll(
288302
'__FIRST_REQUEST_MARKER__',
@@ -296,6 +310,10 @@ function renderNginxConf(args: {
296310
return joinPaths(args.tempDirKernelPath, 'nginx.conf');
297311
}
298312

313+
function toPosixSeparators(p: string): string {
314+
return p.replace(/\\/g, '/');
315+
}
316+
299317
function spawnPhpFpm(
300318
host: NodeKernelHost,
301319
bytes: ArrayBuffer,

packages/playground/cli/src/posix-kernel/configs/nginx.conf

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,15 @@ events {
1111
http {
1212
access_log off;
1313

14+
# Override every temp-path directive — the nginx WASM was built with
15+
# `--http-*-temp-path=/tmp/nginx_*_temp`, and nginx mkdirs all of them
16+
# at startup regardless of whether `proxy_pass` etc. are configured.
17+
# The kernel does not rewrite `/tmp/`, so on Windows (where the host
18+
# fs has no `/tmp/`) every mkdir fails and nginx aborts. (scgi/uwsgi
19+
# modules aren't compiled into this nginx, so no overrides needed.)
1420
client_body_temp_path __TEMP_DIR__/client_body_temp;
21+
fastcgi_temp_path __TEMP_DIR__/fastcgi_temp;
22+
proxy_temp_path __TEMP_DIR__/proxy_temp;
1523

1624
# Every request goes to PHP-FPM via the router script. The router
1725
# serves static files directly with the right Content-Type and
@@ -24,9 +32,9 @@ http {
2432
root __WORDPRESS_ROOT__;
2533

2634
location / {
27-
fastcgi_pass 127.0.0.1:9000;
35+
fastcgi_pass 127.0.0.1:__FPM_PORT__;
2836
fastcgi_param SCRIPT_FILENAME __ROUTER_SCRIPT__;
29-
fastcgi_param DOCUMENT_ROOT $document_root;
37+
fastcgi_param DOCUMENT_ROOT __DOCUMENT_ROOT__;
3038
fastcgi_param DOCUMENT_URI $document_uri;
3139
fastcgi_param QUERY_STRING $query_string;
3240
fastcgi_param REQUEST_METHOD $request_method;

packages/playground/cli/src/posix-kernel/configs/php-fpm.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ error_log = /dev/stderr
44
log_level = notice
55

66
[www]
7-
listen = 127.0.0.1:9000
7+
listen = 127.0.0.1:__FPM_PORT__
88

99
; The kernel runs every process as root; php-fpm refuses to start
1010
; without an explicit user/group when invoked as root, even though

packages/playground/cli/src/posix-kernel/php-api.ts

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -45,25 +45,23 @@ const VFS_DOCROOT_IN_CODE = /(?<![\w/-])\/wordpress(?=$|[/"'`\s\\,;:)$])/g;
4545
export interface KernelLimitedPHPApiOptions {
4646
serverUrl: string;
4747
wordPressRootHostPath: string;
48-
wordPressRootKernelPath: string;
4948
phpWasmPath: string;
5049
runtime: KernelRuntime;
5150
}
5251

5352
export class KernelLimitedPHPApi {
5453
readonly absoluteUrl: string;
5554
/**
56-
* Kernel-side WordPress doc root. Not the VFS literal `/wordpress`.
55+
* Host filesystem doc root, not the VFS literal `/wordpress`.
5756
* Blueprint v1 steps embed `documentRoot` into PHP source via
5857
* `phpVar`, which base64-encodes the value past
5958
* `translateVfsPathsInCode`'s rewrite. The kernel-spawned `php -r`
6059
* resolves `${documentRoot}/wp-load.php` against this path; the
61-
* kernel's `/dev/shm/` rewrite then maps it back to the same bytes
62-
* nginx + php-fpm serve via the host path.
60+
* kernel passes the host path through `fs.*` unchanged (only
61+
* `/dev/shm/` is rewritten).
6362
*/
6463
readonly documentRoot: string;
6564
private readonly hostRoot: string;
66-
private readonly kernelRoot: string;
6765
private readonly runtime: KernelRuntime;
6866
private readonly phpWasmBytes: ArrayBuffer;
6967
private readonly cookieJar = new Map<string, string>();
@@ -77,8 +75,7 @@ export class KernelLimitedPHPApi {
7775
constructor(options: KernelLimitedPHPApiOptions) {
7876
this.absoluteUrl = options.serverUrl;
7977
this.hostRoot = options.wordPressRootHostPath;
80-
this.kernelRoot = options.wordPressRootKernelPath;
81-
this.documentRoot = this.kernelRoot;
78+
this.documentRoot = this.hostRoot;
8279
this.runtime = options.runtime;
8380
this.phpWasmBytes = readWasm(options.phpWasmPath);
8481
this.definesPluginPath = joinPaths(
@@ -182,7 +179,7 @@ export class KernelLimitedPHPApi {
182179
'php',
183180
'-d',
184181
'display_errors=stderr',
185-
this.toKernel(request.scriptPath),
182+
this.toHost(request.scriptPath),
186183
];
187184
} else {
188185
throw new Error(
@@ -200,7 +197,7 @@ export class KernelLimitedPHPApi {
200197
const { exitCode, stdout, stderr } = await this.runtime.spawnCapturing({
201198
programBytes: this.phpWasmBytes,
202199
argv,
203-
options: { env, cwd: this.kernelRoot, stdin },
200+
options: { env, cwd: this.hostRoot, stdin },
204201
});
205202

206203
return new PHPResponse(
@@ -302,7 +299,7 @@ export class KernelLimitedPHPApi {
302299
const env: Record<string, string> = {
303300
HOME: '/tmp',
304301
PATH: '/usr/local/bin:/usr/bin:/bin',
305-
DOCROOT: this.kernelRoot,
302+
DOCROOT: this.hostRoot,
306303
};
307304
if (extra) {
308305
for (const [k, v] of Object.entries(extra)) {
@@ -313,7 +310,7 @@ export class KernelLimitedPHPApi {
313310
}
314311

315312
private translateVfsPathsInCode(code: string): string {
316-
return code.replace(VFS_DOCROOT_IN_CODE, this.kernelRoot);
313+
return code.replace(VFS_DOCROOT_IN_CODE, this.hostRoot);
317314
}
318315

319316
private serializeCookies(): string {
@@ -366,29 +363,6 @@ export class KernelLimitedPHPApi {
366363
vfsPath.slice(VFS_DOCUMENT_ROOT.length)
367364
);
368365
}
369-
// Blueprint v1 builds paths from `documentRoot` (kernelRoot).
370-
// When those paths flow back into fs methods Node would call
371-
// `fs.openSync('/dev/shm/...')` and EPERM on macOS; translate
372-
// the kernel prefix back to the host one first.
373-
if (vfsPath === this.kernelRoot) {
374-
return this.hostRoot;
375-
}
376-
if (vfsPath.startsWith(this.kernelRoot + '/')) {
377-
return this.hostRoot + vfsPath.slice(this.kernelRoot.length);
378-
}
379-
return vfsPath;
380-
}
381-
382-
private toKernel(vfsPath: string): string {
383-
if (vfsPath === VFS_DOCUMENT_ROOT) {
384-
return this.kernelRoot;
385-
}
386-
if (vfsPath.startsWith(VFS_DOCUMENT_ROOT + '/')) {
387-
return joinPaths(
388-
this.kernelRoot,
389-
vfsPath.slice(VFS_DOCUMENT_ROOT.length)
390-
);
391-
}
392366
return vfsPath;
393367
}
394368
}

packages/playground/cli/src/posix-kernel/posix-kernel-handler.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
runBlueprintV1Steps,
77
} from '@wp-playground/blueprints';
88
import { RecommendedPHPVersion } from '@wp-playground/common';
9+
import { mkdirSync } from 'node:fs';
910
import path from 'path';
1011
import { joinPaths } from '@php-wasm/util';
1112
import { type Mount } from '@php-wasm/cli-util';
@@ -63,20 +64,20 @@ export class PosixKernelHandler {
6364
const tempDir = await createPosixKernelTempDir();
6465

6566
let wordPressRootHostPath: string;
66-
let wordPressRootKernelPath: string;
67+
// Value of nginx's `root` directive. Stays kernel-shaped because
68+
// nginx's POSIX argv parser rejects `C:\…` as relative — PHP-FPM
69+
// reads the real host path via the hardcoded `DOCUMENT_ROOT`
70+
// fastcgi_param instead.
71+
const wordPressRootKernelPath = joinPaths(
72+
tempDir.kernelPath,
73+
'wordpress'
74+
);
75+
const nginxRootHostPath = path.join(tempDir.hostPath, 'wordpress');
6776
if (wordPressMount) {
68-
// User-supplied --mount=/wordpress points at an arbitrary
69-
// host directory that we can't relocate under /dev/shm/.
70-
// On Linux/macOS the absolute path is already POSIX so the
71-
// kernel accepts it; on Windows this branch still fails.
7277
wordPressRootHostPath = path.resolve(wordPressMount.hostPath);
73-
wordPressRootKernelPath = wordPressRootHostPath;
78+
mkdirSync(nginxRootHostPath, { recursive: true });
7479
} else {
75-
wordPressRootHostPath = path.join(tempDir.hostPath, 'wordpress');
76-
wordPressRootKernelPath = joinPaths(
77-
tempDir.kernelPath,
78-
'wordpress'
79-
);
80+
wordPressRootHostPath = nginxRootHostPath;
8081
try {
8182
await prepareWordPressForPosixKernel({
8283
wordPressRoot: wordPressRootHostPath,
@@ -118,7 +119,6 @@ export class PosixKernelHandler {
118119
const api = new KernelLimitedPHPApi({
119120
serverUrl: booted.serverUrl,
120121
wordPressRootHostPath,
121-
wordPressRootKernelPath,
122122
phpWasmPath: booted.runtime.phpWasmPath,
123123
runtime: booted.runtime,
124124
});

packages/playground/cli/tests/posix-kernel/php-api.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ describe('--experimental-posix-kernel KernelLimitedPHPApi.run stdout capture', (
4747
api = new KernelLimitedPHPApi({
4848
serverUrl: booted.serverUrl,
4949
wordPressRootHostPath,
50-
wordPressRootKernelPath,
5150
phpWasmPath: booted.runtime.phpWasmPath,
5251
runtime: booted.runtime,
5352
});

packages/playground/cli/tests/posix-kernel/run-cli.spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,11 @@ describe(
159159
);
160160
expect(response.status).toBe(200);
161161
const text = await response.text();
162-
expect(text).toContain('http://127.0.0.1:9500');
162+
// Compare against `cliServer.serverUrl` instead of the
163+
// hard-coded `:9500` so the test still passes when another
164+
// concurrent test holds 9500 and the handler falls back to
165+
// a random port via `reserveFreePort()`.
166+
expect(text).toContain(cliServer.serverUrl);
163167
});
164168
},
165169
60_000 * 5

0 commit comments

Comments
 (0)