Skip to content
Draft
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
46 changes: 46 additions & 0 deletions packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,52 @@ describe('journalFSEventsToOpfs', () => {
expect(decode(opfsRoot.files.get('file.txt')!.bytes)).toBe('saved');
});

it('holds the configured durability lock while flushing journaled changes', async () => {
const lockRequested = deferred<void>();
const grantLock = deferred<void>();
const requestLock = vi.fn(
async (
_name: string,
_options: LockOptions,
callback: () => Promise<void>
) => {
lockRequested.resolve();
await grantLock.promise;
return await callback();
}
);
vi.stubGlobal('navigator', { locks: { request: requestLock } });

try {
const { FS, files, php } = createFakePhp();
const opfsRoot = new MemoryDirectoryHandle('root');
const mount = journalFSEventsToOpfs(
php,
opfsRoot as unknown as FileSystemDirectoryHandle,
'/wordpress',
{ durabilityLockName: 'site-durability-lock' }
);

files.set('/wordpress/file.txt', encode('saved'));
FS.write({ path: '/wordpress/file.txt' });
const flush = mount.flush();
await lockRequested.promise;

expect(opfsRoot.files.has('file.txt')).toBe(false);
expect(requestLock).toHaveBeenCalledWith(
'site-durability-lock',
{ mode: 'exclusive' },
expect.any(Function)
);

grantLock.resolve();
await flush;
expect(decode(opfsRoot.files.get('file.txt')!.bytes)).toBe('saved');
} finally {
vi.unstubAllGlobals();
}
});

it.each(['filesystem.write', 'request.end'] as const)(
'flushes pending writes when %s is dispatched',
async (eventType) => {
Expand Down
22 changes: 20 additions & 2 deletions packages/php-wasm/web/src/lib/directory-handle-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export interface MountOptions {
direction?: 'opfs-to-memfs' | 'memfs-to-opfs';
onProgress?: SyncProgressCallback;
};
/** Origin-wide Web Lock that guards journal writes against direct readers. */
durabilityLockName?: string;
onMount?: (mount: DirectoryHandleMount) => void;
}
export interface DirectoryHandleMount {
Expand Down Expand Up @@ -70,6 +72,7 @@ export type SyncProgressCallback = (
) => void | Promise<void>;

interface JournalFSEventsToOpfsOptions {
durabilityLockName?: string;
maxFlushPasses?: number;
}

Expand Down Expand Up @@ -103,11 +106,15 @@ export function createDirectoryHandleMountHandler(
}
FSHelpers.mkdir(FS, vfsMountPoint);
await copyOpfsToMemfs(FS, handle, vfsMountPoint);
const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint);
const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint, {
durabilityLockName: options.durabilityLockName,
});
options.onMount?.(mount);
return mount.unmount;
} else {
const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint);
const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint, {
durabilityLockName: options.durabilityLockName,
});
options.onMount?.(mount);
let lastProgress: SyncProgress | undefined;
try {
Expand Down Expand Up @@ -456,6 +463,17 @@ export function journalFSEventsToOpfs(
}

async function flushJournal() {
if (!options.durabilityLockName || !globalThis.navigator?.locks) {
return await drainJournal();
}
return await globalThis.navigator.locks.request(
options.durabilityLockName,
{ mode: 'exclusive' },
drainJournal
);
}

async function drainJournal() {
const maxFlushPasses =
options.maxFlushPasses ?? DEFAULT_MAX_OPFS_FLUSH_PASSES;
for (let pass = 0; journal.length > 0; pass++) {
Expand Down
20 changes: 20 additions & 0 deletions packages/playground/remote/src/lib/playground-worker-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export interface MountDescriptor {
mountpoint: string;
device: MountDevice;
initialSyncDirection: 'opfs-to-memfs' | 'memfs-to-opfs';
/** Origin-wide Web Lock that guards journal writes against direct readers. */
durabilityLockName?: string;
}

export type WorkerBootOptions = {
Expand Down Expand Up @@ -246,6 +248,23 @@ export abstract class PlaygroundWorkerEndpoint extends PHPWorker {
);

if (!isPrimary) {
/**
* Secondary PHP instances write through the primary PHP's filesystem.
* Their request-end event stays on the secondary instance, so trigger
* every active mount journal here after those proxied writes finish.
*/
php.addEventListener('request.end', () => {
for (const [mountpoint, mount] of Object.entries(
this.opfsMounts
)) {
void mount.flush().catch((error) => {
logger.error(
`OPFS flush failed after a pooled PHP request at "${mountpoint}"`,
error
);
});
}
});
const pathsToShareBetweenPhpInstances = [
'/tmp',
requestHandler.documentRoot,
Expand Down Expand Up @@ -562,6 +581,7 @@ export abstract class PlaygroundWorkerEndpoint extends PHPWorker {
onProgress,
direction: options.initialSyncDirection,
},
durabilityLockName: options.durabilityLockName,
onMount(mount) {
opfsMount = mount;
},
Expand Down
77 changes: 77 additions & 0 deletions packages/playground/website/playwright/e2e/opfs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,83 @@ test('should start a new Playground after an initial OPFS sync was interrupted',
await website.waitForNestedIframes();
});

test('should flush pooled PHP request writes to an autosaved OPFS site', async ({
website,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
`This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.`
);

await website.goto(`./?random=${Date.now()}`);
await website.page.waitForFunction(() =>
Boolean((window as any).playgroundSites?.getClient())
);
await expect(
website.page.getByRole('button', { name: 'Autosaved' })
).toBeVisible({ timeout: 120000 });
const site = await getActivePlaygroundSite(website.page);
await waitForInitialOpfsSync(website.page, site.slug);

const markerName = `pooled-request-${Date.now()}.txt`;
const markerContents = 'written by the pooled PHP instance';
const liveMarkerContents = await website.page.evaluate(
async ({ markerName, markerContents }) => {
const playground = (window as any).playgroundSites.getClient();
const documentRoot = await playground.documentRoot;
const markerPath = `${documentRoot}/wp-content/${markerName}`;
const primaryRequest = playground.run({
code: '<?php usleep(500000);',
});
await new Promise((resolve) => setTimeout(resolve, 50));
const pooledRequest = playground.run({
code: `<?php usleep(750000); file_put_contents(${JSON.stringify(
markerPath
)}, ${JSON.stringify(markerContents)});`,
});
await Promise.all([primaryRequest, pooledRequest]);
return await playground.readFileAsText(markerPath);
},
{ markerName, markerContents }
);
expect(liveMarkerContents).toBe(markerContents);

await expect
.poll(
() =>
website.page.evaluate(
async ({ directoryName, markerName }) => {
try {
const root = await navigator.storage.getDirectory();
const sites =
await root.getDirectoryHandle('sites');
const siteDirectory =
await sites.getDirectoryHandle(directoryName);
const wpContent =
await siteDirectory.getDirectoryHandle(
'wp-content'
);
const marker =
await wpContent.getFileHandle(markerName);
return await (await marker.getFile()).text();
} catch (error) {
if (error?.name === 'NotFoundError') {
return undefined;
}
throw error;
}
},
{
directoryName: getDirectoryNameForSlug(site.slug),
markerName,
}
),
{ timeout: 3000 }
)
.toBe(markerContents);
});

test('should switch between sites', async ({ website, browserName }) => {
test.skip(
browserName !== 'chromium',
Expand Down
10 changes: 10 additions & 0 deletions packages/playground/website/src/lib/state/opfs/opfs-site-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ export function getDirectoryPathForSlug(slug: string) {
return joinPaths(OPFS_SITES_ROOT_PATH, getDirectoryNameForSlug(slug));
}

/**
* Returns the origin-wide Web Lock name that guards a saved site's OPFS files.
*
* Use the actual directory path so legacy and encoded site directories use the
* same lock in both the runtime that writes them and direct readers.
*/
export function getOpfsSiteDurabilityLockName(siteDirectoryPath: string) {
return `wordpress-playground:opfs-durability:${siteDirectoryPath}`;
}

/**
* Returns the OPFS directory name for a site slug.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,53 @@ describe('opfsSiteStorage', () => {
expect(archive.directories.get('wp-content/empty-cache/')).toBe(0o755);
});

it('waits for the shared durability lock before traversing files for export', async () => {
const lockRequested = deferred<void>();
const grantLock = deferred<void>();
const requestLock = vi.fn(
async (
_name: string,
_options: LockOptions,
callback: () => Promise<Blob>
) => {
lockRequested.resolve();
await grantLock.promise;
return await callback();
}
);
(navigator as any).locks = { request: requestLock };
const sitesRoot = await getSitesRoot(opfsRoot);
const siteDirectory = await writeSiteMetadata(
sitesRoot,
'site-durability',
'durability'
);
siteDirectory.setFile('marker.txt', 'durable');
const readEntries = vi.spyOn(siteDirectory, 'entries');

let exportSettled = false;
const exportPromise = storage
.exportSavedSiteAsZip('durability')
.then((zipFile) => {
exportSettled = true;
return zipFile;
});
await lockRequested.promise;

expect(readEntries).not.toHaveBeenCalled();
expect(exportSettled).toBe(false);
expect(requestLock).toHaveBeenCalledWith(
'wordpress-playground:opfs-durability:/sites/site-durability',
{ mode: 'shared' },
expect.any(Function)
);

grantLock.resolve();
const zipFile = await exportPromise;
const archive = await readZipEntries(zipFile!);
expect(archive.files.get('marker.txt')).toBe('durable');
});

it('applies ordered exclusion patterns when exporting saved site files', async () => {
const sitesRoot = await getSitesRoot(opfsRoot);
const siteDirectory = await writeSiteMetadata(
Expand Down Expand Up @@ -573,3 +620,11 @@ function createDomException(name: string) {
error.name = name;
return error;
}

function deferred<T>() {
let resolve: (value: T | PromiseLike<T>) => void = () => {};
const promise = new Promise<T>((resolver) => {
resolve = resolver;
});
return { promise, resolve };
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ import {
OPFS_SITES_ROOT_PATH,
getCandidateDirectoryNamesForSlug,
getDirectoryNameForSlug,
getOpfsSiteDurabilityLockName,
} from './opfs-site-path';
export {
getDirectoryNameForSlug,
getDirectoryPathForSlug,
getOpfsSiteDurabilityLockName,
} from './opfs-site-path';

// TODO: Decide on metadata filename
Expand Down Expand Up @@ -237,7 +239,9 @@ class OpfsSiteStorage {
if (!siteDirectory) {
return undefined;
}
return await zipDirectory(siteDirectory, options.excludePatterns);
return await this.withSiteDurabilityLock(siteDirectory.name, () =>
zipDirectory(siteDirectory, options.excludePatterns)
);
}

/**
Expand Down Expand Up @@ -279,6 +283,24 @@ class OpfsSiteStorage {
}
}

private async withSiteDurabilityLock<T>(
siteDirectoryName: string,
operation: () => Promise<T>
): Promise<T> {
if (!navigator.locks) {
return await operation();
}
const siteDirectoryPath = joinPaths(
OPFS_SITES_ROOT_PATH,
siteDirectoryName
);
return await navigator.locks.request(
getOpfsSiteDurabilityLockName(siteDirectoryPath),
{ mode: 'shared' },
operation
);
}

private async readSite(siteDirName: string) {
const siteDirectory = await this.root.getDirectoryHandle(siteDirName);
if (!siteDirectory) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ vi.mock('./store', () => ({

vi.mock('../opfs/opfs-site-storage', () => ({
getDirectoryPathForSlug: (slug: string) => `/sites/${slug}`,
getOpfsSiteDurabilityLockName: (path: string) => `durability:${path}`,
legacyOpfsPathSymbol: Symbol('legacyOpfsPath'),
opfsSiteStorage: {
removeWordPressFilesKeepMetadata: vi.fn(),
Expand Down Expand Up @@ -442,6 +443,8 @@ describe('bootSiteClient', () => {
expect.objectContaining({
mounts: [
expect.objectContaining({
durabilityLockName:
'durability:/sites/site-stored-save',
device: expect.objectContaining({
path: '/sites/site-stored-save',
}),
Expand Down
Loading
Loading