Skip to content
Open
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
103 changes: 103 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 @@ -39,6 +39,12 @@ class MemoryFileHandle {
seek: async () => {},
};
}

async getFile() {
return {
arrayBuffer: async () => this.bytes.buffer.slice(0),
};
}
}

class MemoryDirectoryHandle {
Expand Down Expand Up @@ -81,6 +87,11 @@ class MemoryDirectoryHandle {
this.files.delete(name);
this.directories.delete(name);
}

async *values() {
yield* this.directories.values();
yield* this.files.values();
}
}

describe('journalFSEventsToOpfs', () => {
Expand Down Expand Up @@ -546,6 +557,93 @@ describe('createDirectoryHandleMountHandler', () => {
vi.useRealTimers();
}
});

it('reports the failed OPFS entry when initial OPFS to MEMFS sync fails', async () => {
const rootCause = new Error('file could not be read');
const { FS, php } = createFakePhp();
FS.lookupPath.mockImplementation((path: string) => {
if (path === '/wordpress') {
throw new Error(`Missing file: ${path}`);
}
return {
path,
node: { mode: 0, path },
};
});
const opfsRoot = new MemoryDirectoryHandle('root');
const brokenFile = new MemoryFileHandle('database.sqlite');
vi.spyOn(brokenFile, 'getFile').mockRejectedValue(rootCause);
opfsRoot.files.set('database.sqlite', brokenFile);

const mountHandler = createDirectoryHandleMountHandler(
opfsRoot as unknown as FileSystemDirectoryHandle,
{
initialSync: {
direction: 'opfs-to-memfs',
},
}
);

await expect(
mountHandler(php, FS as any, '/wordpress')
).rejects.toMatchObject({
message:
'Failed to copy OPFS entry to MEMFS path ' +
'"/wordpress/database.sqlite": file could not be read',
cause: rootCause,
});
});

it('observes pending OPFS copy operations before reporting a sync failure', async () => {
const rootCause = new Error('file could not be read');
const pendingRead = deferred<void>();
const { FS, php } = createFakePhp();
FS.lookupPath.mockImplementation((path: string) => {
if (path === '/wordpress') {
throw new Error(`Missing file: ${path}`);
}
return {
path,
node: { mode: 0, path },
};
});
const opfsRoot = new MemoryDirectoryHandle('root');
const brokenFile = new MemoryFileHandle('database.sqlite');
const pendingFile = new MemoryFileHandle('pending.txt');
vi.spyOn(brokenFile, 'getFile').mockRejectedValue(rootCause);
vi.spyOn(pendingFile, 'getFile').mockImplementation(async () => {
await pendingRead.promise;
return {
arrayBuffer: async () => new Uint8Array().buffer,
};
});
opfsRoot.files.set('database.sqlite', brokenFile);
opfsRoot.files.set('pending.txt', pendingFile);

const mountHandler = createDirectoryHandleMountHandler(
opfsRoot as unknown as FileSystemDirectoryHandle,
{
initialSync: {
direction: 'opfs-to-memfs',
},
}
);
let rejected = false;
const syncPromise = Promise.resolve(
mountHandler(php, FS as any, '/wordpress')
).catch((error: unknown) => {
rejected = true;
throw error;
});

await Promise.resolve();
expect(rejected).toBe(false);

pendingRead.resolve();
await expect(syncPromise).rejects.toMatchObject({
cause: rootCause,
});
});
});

function createFakePhp() {
Expand Down Expand Up @@ -574,6 +672,11 @@ function createFakePhp() {
}
return file;
}),
createDataFile: vi.fn(
(parentPath: string, name: string, bytes: Uint8Array) => {
files.set(`${parentPath}/${name}`, bytes);
}
),
};
const listeners = new Map<string, Set<(event: { type: string }) => void>>();
const addEventListener = vi.fn(
Expand Down
97 changes: 68 additions & 29 deletions packages/php-wasm/web/src/lib/directory-handle-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,54 +134,93 @@ async function copyOpfsToMemfs(
concurrency: 40,
});

const ops: Array<Promise<void>> = [];
type CopyOperationResult =
| { status: 'fulfilled' }
| { status: 'rejected'; reason: unknown };
const ops: Array<Promise<CopyOperationResult>> = [];
let firstError: unknown;
const stack: Array<[FileSystemDirectoryHandle, string]> = [
[opfsRoot, memfsRoot],
];
while (stack.length > 0) {
const [opfsParent, memfsParentPath] = stack.pop()!;

for await (const opfsHandle of opfsParent.values()) {
const op = semaphore.run(async () => {
const memfsEntryPath = joinPaths(
memfsParentPath,
opfsHandle.name
);
if (opfsHandle.kind === 'directory') {
const memfsEntryPath = joinPaths(memfsParentPath, opfsHandle.name);
const op = semaphore
.run(async () => {
try {
FS.mkdir(memfsEntryPath);
} catch (e) {
if ((e as any)?.errno !== 20) {
logger.error(e);
// We ignore the error if the directory already exists,
// and throw otherwise.
throw e;
if (opfsHandle.kind === 'directory') {
try {
FS.mkdir(memfsEntryPath);
} catch (e) {
if ((e as any)?.errno !== 20) {
logger.error(e);
// We ignore the error if the directory
// already exists, and throw otherwise.
throw e;
}
}
stack.push([opfsHandle, memfsEntryPath]);
} else if (opfsHandle.kind === 'file') {
const file = await opfsHandle.getFile();
const byteArray = new Uint8Array(
await file.arrayBuffer()
);
FS.createDataFile(
memfsParentPath,
opfsHandle.name,
byteArray,
true,
true,
true
);
}
} catch (error) {
throw new Error(
`Failed to copy OPFS entry to MEMFS path "${memfsEntryPath}"` +
`: ${getErrorMessage(error)}`,
{ cause: error }
);
Comment on lines +180 to +184
}
stack.push([opfsHandle, memfsEntryPath]);
} else if (opfsHandle.kind === 'file') {
const file = await opfsHandle.getFile();
const byteArray = new Uint8Array(await file.arrayBuffer());
FS.createDataFile(
memfsParentPath,
opfsHandle.name,
byteArray,
true,
true,
true
);
})
.then(
() => ({ status: 'fulfilled' }) as const,
(error) => {
firstError ??= error;
return { status: 'rejected', reason: error } as const;
}
);
const trackedOp = op.finally(() => {
const opIndex = ops.indexOf(trackedOp);
if (opIndex !== -1) {
ops.splice(opIndex, 1);
}
ops.splice(ops.indexOf(op), 1);
});
ops.push(op);
ops.push(trackedOp);
}
// Let the ongoing operations catch-up to the stack.
while (stack.length === 0 && ops.length > 0) {
await Promise.any(ops);
if (firstError) {
await Promise.allSettled(ops);
throw firstError;
}
const result = await Promise.race(ops);
if (result.status === 'rejected') {
await Promise.allSettled(ops);
throw result.reason;
}
}
Comment on lines 203 to 213
}
}

function getErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return String(error);
}

export async function copyMemfsToOpfs(
FS: Emscripten.RootFS,
opfsRoot: FileSystemDirectoryHandle,
Expand Down
Loading