Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions packages/php-wasm/node/src/lib/node-fs-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,20 @@ import {
FSHelpers,
type MountHandler,
} from '@php-wasm/universal';
import { isParentOf } from '@php-wasm/util';
import { lstatSync } from 'fs';
import { dirname } from 'path';

export function createNodeFsMountHandler(localPath: string): MountHandler {
export type NodeFsMountHandlerOptions = {
cleanupNodesOnUnmount?: boolean;
};

export function createNodeFsMountHandler(
localPath: string,
options: NodeFsMountHandlerOptions = {
cleanupNodesOnUnmount: false,
}
): MountHandler {
return function (php, FS, vfsMountPoint) {
/**
* When Emscripten attempt to mount a local path into VFS, it looks up the path
Expand Down Expand Up @@ -51,8 +61,14 @@ export function createNodeFsMountHandler(localPath: string): MountHandler {
FS.mount(FS.filesystems['NODEFS'], { root: localPath }, vfsMountPoint);
return () => {
FS!.unmount(vfsMountPoint);
if (removeVfsNode) {
if (removeVfsNode && options.cleanupNodesOnUnmount) {
if (FS.isDir(lookup.node.mode)) {
if (isParentOf(vfsMountPoint, FS.cwd())) {
throw new Error(
`Cannot remove the VFS directory "${vfsMountPoint}" on umount cleanup – it is a parent of the CWD "${FS.cwd()}". Change CWD before ` +
`unmounting or explicitly disable post-unmount node cleanup with createNodeFsMountHandler(path, {cleanupNodesOnUnmount: false}).`
);
}
FS.rmdir(vfsMountPoint);
} else {
FS.unlink(vfsMountPoint);
Expand Down
197 changes: 183 additions & 14 deletions packages/php-wasm/node/src/test/mount.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,17 @@ describe('Mounting', () => {
expect(php.isDir(fileMountPoint)).toBe(false);
});

it('Should unmount mounted file and remove created node from VFS', async () => {
it('Should unmount mounted file and remove created node from VFS (with cleanupNodesOnUnmount=true)', async () => {
const unmount = await php.mount(
fileMountPoint,
createNodeFsMountHandler(filePath)
createNodeFsMountHandler(filePath, {
cleanupNodesOnUnmount: true,
})
);

expect(php.isFile(fileMountPoint)).toBe(true);

unmount();
await unmount();
expect(php.isFile(fileMountPoint)).toBe(false);
});

Expand All @@ -172,7 +174,7 @@ describe('Mounting', () => {
createNodeFsMountHandler(filePath)
);

unmount();
await unmount();
await php.mount(
fileMountPoint,
createNodeFsMountHandler(filePath)
Expand All @@ -196,7 +198,7 @@ describe('Mounting', () => {

expect(php.isFile(mountPoint)).toBe(true);

unmount();
await unmount();
expect(php.isDir(dirname(mountPoint))).toBe(true);
});
});
Expand Down Expand Up @@ -498,38 +500,44 @@ describe('Mounting', () => {
}
});

it('Should unmount mounted directory and remove created node from VFS', async () => {
it('Should unmount mounted directory and remove created node from VFS (with cleanupNodesOnUnmount=true)', async () => {
const unmount = await php.mount(
directoryMountPoint,
createNodeFsMountHandler(directoryPath)
createNodeFsMountHandler(directoryPath, {
cleanupNodesOnUnmount: true,
})
);

expect(php.isDir(directoryMountPoint)).toBe(true);

unmount();
await unmount();
expect(php.isDir(directoryMountPoint)).toBe(false);
});

it('Should unmount mounted directory, but not remove the parent directory from VFS if it was created manually', async () => {
it('Should unmount mounted directory, but not remove the parent directory from VFS if it was created manually (with cleanupNodesOnUnmount=true)', async () => {
await php.mkdir(directoryMountPoint);
const unmount = await php.mount(
directoryMountPoint,
createNodeFsMountHandler(directoryPath)
createNodeFsMountHandler(directoryPath, {
cleanupNodesOnUnmount: true,
})
);

expect(php.isDir(directoryMountPoint)).toBe(true);

unmount();
await unmount();
expect(php.isDir(directoryMountPoint)).toBe(true);
});

it('Should remount mounted directory after unmounting', async () => {
it('Should remount mounted directory after unmounting (with cleanupNodesOnUnmount=true)', async () => {
const unmount = await php.mount(
directoryMountPoint,
createNodeFsMountHandler(directoryPath)
createNodeFsMountHandler(directoryPath, {
cleanupNodesOnUnmount: true,
})
);

unmount();
await unmount();
await php.mount(
directoryMountPoint,
createNodeFsMountHandler(directoryPath)
Expand Down Expand Up @@ -628,4 +636,165 @@ describe('Mounting', () => {
fs.rmSync(tempBase, { recursive: true, force: true });
}
});

describe('Should respect the cleanupNodesOnUnmount option', () => {
let tempBase = '',
testFile = '',
testDir = '';
beforeAll(() => {
tempBase = fs.mkdtempSync(
path.join(os.tmpdir(), 'playground-cleanup-')
);
testFile = path.join(tempBase, 'test.txt');
testDir = path.join(tempBase, 'testdir');

fs.writeFileSync(testFile, 'test content');
fs.mkdirSync(testDir);
fs.writeFileSync(
path.join(testDir, 'nested.txt'),
'nested content'
);
});

afterAll(() => {
fs.rmSync(tempBase, { recursive: true, force: true });
});

describe('cleanupNodesOnUnmount=false', () => {
it('Should not remove the VFS directory if it did not exist before the mount', async () => {
const unmount = await php.mount(
'/mount-target',
createNodeFsMountHandler(testDir, {
cleanupNodesOnUnmount: false,
})
);

expect(php.isDir('/mount-target')).toBe(true);
await unmount();
expect(php.isDir('/mount-target')).toBe(true);
expect(php.listFiles('/mount-target')).toEqual([]);
});

it('Should not remove the VFS file if it did not exist before the mount', async () => {
const unmount = await php.mount(
'/mount-target',
createNodeFsMountHandler(testFile, {
cleanupNodesOnUnmount: false,
})
);

expect(php.isFile('/mount-target')).toBe(true);
expect(php.readFileAsText('/mount-target')).toBe(
'test content'
);
await unmount();
expect(php.isFile('/mount-target')).toBe(true);
expect(php.readFileAsText('/mount-target')).toBe('');
});
});

describe('cleanupNodesOnUnmount=true', () => {
it('Should remove the VFS directory if it did **not** exist before the mount', async () => {
const unmount = await php.mount(
'/mount-target',
createNodeFsMountHandler(testDir, {
cleanupNodesOnUnmount: true,
})
);

expect(php.isDir('/mount-target')).toBe(true);
await unmount();
expect(php.fileExists('/mount-target')).toBe(false);
});

it('Should remove the VFS file if it did **not** exist before the mount', async () => {
const unmount = await php.mount(
'/mount-target',
createNodeFsMountHandler(testFile, {
cleanupNodesOnUnmount: true,
})
);

expect(php.isFile('/mount-target')).toBe(true);
await unmount();
expect(php.fileExists('/mount-target')).toBe(false);
});

it('Should not remove the VFS directory if it did exist before the mount', async () => {
php.mkdir('/mount-target');
const unmount = await php.mount(
'/mount-target',
createNodeFsMountHandler(testDir, {
cleanupNodesOnUnmount: true,
})
);

expect(php.isDir('/mount-target')).toBe(true);
await unmount();
expect(php.isDir('/mount-target')).toBe(true);
});

it('Should not remove the VFS file if it did exist before the mount', async () => {
php.writeFile('/mount-target', 'Hello, world!');
const unmount = await php.mount(
'/mount-target',
createNodeFsMountHandler(testFile, {
cleanupNodesOnUnmount: true,
})
);

expect(php.isFile('/mount-target')).toBe(true);
await unmount();
expect(php.isFile('/mount-target')).toBe(true);
expect(php.readFileAsText('/mount-target')).toBe(
'Hello, world!'
);
});

it('Should refuse to remove the VFS directory if it is the current CWD', async () => {
const unmount = await php.mount(
'/mount-target',
createNodeFsMountHandler(testDir, {
cleanupNodesOnUnmount: true,
})
);

expect(php.isDir('/mount-target')).toBe(true);
php.chdir('/mount-target');
expect(unmount()).rejects.toThrow(
/Cannot remove the VFS directory/
);
});

it('Should refuse to remove the VFS directory if it is a parent of the current CWD', async () => {
const unmount = await php.mount(
'/mount-target',
createNodeFsMountHandler(testDir, {
cleanupNodesOnUnmount: true,
})
);

expect(php.isDir('/mount-target')).toBe(true);
php.mkdir('/mount-target/subdirectory');
php.chdir('/mount-target/subdirectory');
expect(unmount()).rejects.toThrow(
/Cannot remove the VFS directory/
);
});

it('Should remove the VFS directory if it is a child of the current CWD', async () => {
const unmount = await php.mount(
'/mount-target/subdirectory',
createNodeFsMountHandler(testDir, {
cleanupNodesOnUnmount: true,
})
);

expect(php.isDir('/mount-target/subdirectory')).toBe(true);
php.chdir('/mount-target');
await unmount();
expect(php.isDir('/mount-target')).toBe(true);
});
});
});
});
62 changes: 31 additions & 31 deletions packages/php-wasm/universal/src/lib/php-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,39 +445,39 @@ export class PHPRequestHandler implements AsyncDisposable {

// We need to confirm that the current target file exists because
// file-not-found fallback actions may redirect to non-existent files.
if (primaryPhp.isFile(fsPath)) {
if (fsPath.endsWith('.php')) {
const effectiveRequest: PHPRequest = {
...request,
// Pass along URL with the #fragment filtered out
url: requestedUrl.toString(),
};
const response = await this.#spawnPHPAndDispatchRequest(
effectiveRequest,
fsPath
);

/**
* If the response is but the exit code is non-zero, let's rewrite the
* HTTP status code as 500. We're acting as a HTTP server here and
* this behavior is in line with what Nginx and Apache do.
*/
if (response.ok() && response.exitCode !== 0) {
return new PHPResponse(
500,
response.headers,
response.bytes,
response.errors,
response.exitCode
);
}
return response;
} else {
return this.#serveStaticFile(primaryPhp, fsPath);
}
} else {
if (!primaryPhp.isFile(fsPath)) {
return PHPResponse.forHttpCode(404);
}

if (!fsPath.endsWith('.php')) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is just code reformatting, I'll move this to another pr

return this.#serveStaticFile(primaryPhp, fsPath);
}

const effectiveRequest: PHPRequest = {
...request,
// Pass along URL with the #fragment filtered out
url: requestedUrl.toString(),
};
const response = await this.#spawnPHPAndDispatchRequest(
effectiveRequest,
fsPath
);

/**
* If the response is but the exit code is non-zero, let's rewrite the
* HTTP status code as 500. We're acting as a HTTP server here and
* this behavior is in line with what Nginx and Apache do.
*/
if (response.ok() && response.exitCode !== 0) {
return new PHPResponse(
500,
response.headers,
response.bytes,
response.errors,
response.exitCode
);
}
return response;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/php-wasm/universal/src/lib/php.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1433,7 +1433,7 @@ export class PHP implements Disposable {
};
this.#mounts[virtualFSPath] = mountObject;
return () => {
mountObject.unmount();
return mountObject.unmount();
};
}

Expand Down
7 changes: 6 additions & 1 deletion packages/playground/cli/src/run-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,12 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
}
return new PHPResponse(302, headers, new Uint8Array());
}
return await loadBalancer.handleRequest(request);
try {
return await loadBalancer.handleRequest(request);
} catch (e) {
logger.error(e);
return PHPResponse.forHttpCode(500);
}
},
});
}
Expand Down
Loading