Skip to content

Commit

Permalink
Persistent Playground: Sync between MEMFS and OPFS (#546)
Browse files Browse the repository at this point in the history
## Description

#544 explores a full Emscripten OPFS filesystem backend, but 
there is one last issue I may not be able to figure out before my
Sabbatical (June 26th - Sep 26th).

This PR attempts another approach I should be able to ship. Namely,
it synchronizes MEMFS changes to OPFS and restores them after a 
page refresh.

The main idea is:

1. Keep track of all modified files
2. Only sync files on that list

OPFS is only supported in Chrome-based browsers at the moment like Edge,
Android browser. Safari and Firefox users won't be able to benefit from
this feature yet

## Performance

* Full WordPress OPFS->MEMFS: ~340 ms
* Full WordPress MEMFS->OPFS: ~506 ms
* Typical sync MEMFS->OPFS: 2.5 ms

## Other explored approaches

This approach failed:

1. Compare last modified time
2. Copy MEMFS files to OPFS if they were updated more recently

`mtime` doesn't bubble up through directories and comparing all files is
too slow.
  • Loading branch information
adamziel authored Jun 13, 2023
1 parent 06ceb10 commit 11e1fc8
Show file tree
Hide file tree
Showing 29 changed files with 819 additions and 99 deletions.
1 change: 1 addition & 0 deletions packages/docs/site/docs/08-query-api/01-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ You can go ahead and try it out. The Playground will automatically install the t
| `mode` | `seamless` | Displays WordPress on a full-page or wraps it in a browser UI |
| `login` | `1` | Logs the user in as an admin |
| `gutenberg-pr` | | Loads the specified Gutenberg Pull Request |
| `persistent` | | Enables persistent storage for the Playground and protects the user from accidentally losing their work upon page refresh. |

For example, the following code embeds a Playground with a preinstalled Gutenberg plugin, and opens the post editor:

Expand Down
2 changes: 2 additions & 0 deletions packages/php-wasm/compile/build-assets/esm-suffix.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ if (PHPLoader.debug && typeof Asyncify !== "undefined") {
}
}

PHPLoader.PATH = PATH;

return PHPLoader;

// Close the opening bracket from esm-prefix.js:
Expand Down
14 changes: 14 additions & 0 deletions packages/php-wasm/util/src/lib/semaphore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,18 @@ describe('RequestsPerIntervaledSemaphore', () => {

expect(concurrencyTasks).toBe(concurrency);
});
it('should not be possible to release twice', async () => {
const concurrency = 2;
const semaphore = new Semaphore({
concurrency,
});

const release1 = await semaphore.acquire();
await semaphore.acquire();

release1();
release1();

expect(semaphore.running).toBe(1);
});
});
5 changes: 5 additions & 0 deletions packages/php-wasm/util/src/lib/semaphore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ export default class Semaphore {
} else {
// Acquire the lock:
this._running++;
let released = false;
return () => {
if (released) {
return;
}
released = true;
this._running--;
// Release the lock:
if (this.queue.length > 0) {
Expand Down
2 changes: 2 additions & 0 deletions packages/php-wasm/web/public/php_5_6.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/php-wasm/web/public/php_7_0.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/php-wasm/web/public/php_7_1.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/php-wasm/web/public/php_7_2.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/php-wasm/web/public/php_7_3.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/php-wasm/web/public/php_7_4.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/php-wasm/web/public/php_8_0.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/php-wasm/web/public/php_8_1.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/php-wasm/web/public/php_8_2.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 25 additions & 1 deletion packages/php-wasm/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,37 @@ export function consumeAPI<APIType>(
return new Proxy(methods, {
get: (target, prop) => {
if (prop === 'isConnected') {
return () => api.isConnected();
return async () => {
/*
* If exposeAPI() is called after this function,
* the isConnected() call will hang forever. Let's
* retry it a few times.
*/
for (let i = 0; i < 10; i++) {
try {
await runWithTimeout(api.isConnected(), 200);
break;
} catch (e) {
// Timeout exceeded, try again
}
}
};
}
return (api as any)[prop];
},
}) as unknown as RemoteAPI<APIType>;
}

async function runWithTimeout<T>(
promise: Promise<T>,
timeout: number
): Promise<T> {
return new Promise<T>((resolve, reject) => {
setTimeout(reject, timeout);
promise.then(resolve);
});
}

export type PublicAPI<Methods, PipedAPI = unknown> = RemoteAPI<
Methods & PipedAPI
>;
Expand Down
2 changes: 0 additions & 2 deletions packages/php-wasm/web/src/lib/web-php.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,12 @@ export class WebPHP extends BasePHP {
dataModules
);
php.initializeRuntime(runtimeId);
return { dataModules };
};
const asyncData = doLoad();

return {
php,
phpReady: asyncData.then(() => php),
dataModules: asyncData.then((data) => data.dataModules),
};
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { UniversalPHP } from '@php-wasm/universal';
import { StepHandler } from '..';
import { updateFile } from '../common';
import { defineWpConfigConsts } from '../define-wp-config-consts';

export interface ApplyWordPressPatchesStep {
step: 'applyWordPressPatches';
siteUrl: string;
siteUrl?: string;
wordpressPath?: string;
patchSqlitePlugin?: boolean;
addPhpInfo?: boolean;
patchSiteUrl?: boolean;
patchSecrets?: boolean;
disableSiteHealth?: boolean;
disableWpNewBlogNotification?: boolean;
}
Expand All @@ -18,56 +18,42 @@ export const applyWordPressPatches: StepHandler<
> = async (php, options) => {
const patch = new WordPressPatcher(
php,
options.siteUrl,
options.wordpressPath || '/wordpress'
options.wordpressPath || '/wordpress',
options.siteUrl
);

if (options.patchSqlitePlugin !== false) {
await patch.patchSqlitePlugin();
}
if (options.addPhpInfo !== false) {
if (options.addPhpInfo === true) {
await patch.addPhpInfo();
}
if (options.patchSiteUrl !== false) {
if (options.siteUrl) {
await patch.patchSiteUrl();
}
if (options.disableSiteHealth !== false) {
if (options.patchSecrets === true) {
await patch.patchSecrets();
}
if (options.disableSiteHealth === true) {
await patch.disableSiteHealth();
}
if (options.disableWpNewBlogNotification !== false) {
if (options.disableWpNewBlogNotification === true) {
await patch.disableWpNewBlogNotification();
}
};

class WordPressPatcher {
php: UniversalPHP;
scopedSiteUrl: string;
scopedSiteUrl?: string;
wordpressPath: string;

constructor(
php: UniversalPHP,
scopedSiteUrl: string,
wordpressPath: string
wordpressPath: string,
scopedSiteUrl?: string
) {
this.php = php;
this.scopedSiteUrl = scopedSiteUrl;
this.wordpressPath = wordpressPath;
}

async patchSqlitePlugin() {
// Upstream change proposed in https://github.com/WordPress/sqlite-database-integration/pull/28:
await updateFile(
this.php,
`${this.wordpressPath}/wp-content/plugins/sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-translator.php`,
(contents) => {
return contents.replace(
'if ( false === strtotime( $value ) )',
'if ( $value === "0000-00-00 00:00:00" || false === strtotime( $value ) )'
);
}
);
}

async addPhpInfo() {
await this.php.writeFile(
`${this.wordpressPath}/phpinfo.php`,
Expand All @@ -76,16 +62,30 @@ class WordPressPatcher {
}

async patchSiteUrl() {
await defineWpConfigConsts(this.php, {
consts: {
WP_HOME: this.scopedSiteUrl,
WP_SITEURL: this.scopedSiteUrl,
},
virtualize: true,
});
}

async patchSecrets() {
await updateFile(
this.php,
`${this.wordpressPath}/wp-config.php`,
(contents) =>
`<?php
if(!defined('WP_HOME')) {
define('WP_HOME', "${this.scopedSiteUrl}");
define('WP_SITEURL', "${this.scopedSiteUrl}");
}
?>${contents}`
define('AUTH_KEY', '${randomString(40)}');
define('SECURE_AUTH_KEY', '${randomString(40)}');
define('LOGGED_IN_KEY', '${randomString(40)}');
define('NONCE_KEY', '${randomString(40)}');
define('AUTH_SALT', '${randomString(40)}');
define('SECURE_AUTH_SALT', '${randomString(40)}');
define('LOGGED_IN_SALT', '${randomString(40)}');
define('NONCE_SALT', '${randomString(40)}');
?>${contents.replaceAll("', 'put your unique phrase here'", "__', ''")}`
);
}

Expand All @@ -111,3 +111,12 @@ class WordPressPatcher {
);
}
}

function randomString(length: number) {
const chars =
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+=-[]/.,<>?';
let result = '';
for (let i = length; i > 0; --i)
result += chars[Math.floor(Math.random() * chars.length)];
return result;
}
4 changes: 4 additions & 0 deletions packages/playground/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export async function startPlaygroundNode(
await applyWordPressPatches(playground, {
siteUrl: options.serverUrl,
wordpressPath: options.wordpressPathOnHost,
addPhpInfo: true,
patchSecrets: true,
disableSiteHealth: true,
disableWpNewBlogNotification: true,
});

await allowWpOrgHosts(playground, options.wordpressPathOnHost);
Expand Down
5 changes: 3 additions & 2 deletions packages/playground/remote/src/lib/boot-playground-remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import serviceWorkerPath from '../../service-worker.ts?worker&url';
import { LatestSupportedWordPressVersion } from './get-wordpress-module';
export const serviceWorkerUrl = new URL(serviceWorkerPath, origin);

const query = new URL(document.location.href).searchParams as any;
const query = new URL(document.location.href).searchParams;
export async function bootPlaygroundRemote() {
assertNotInfiniteLoadingLoop();

Expand All @@ -70,6 +70,7 @@ export async function bootPlaygroundRemote() {
await spawnPHPWorkerThread(workerUrl, workerBackend, {
wpVersion,
phpVersion,
persistent: query.has('persistent') ? 'true' : 'false',
})
);

Expand Down Expand Up @@ -142,7 +143,7 @@ export async function bootPlaygroundRemote() {
},
};

await workerApi.isConnected;
await workerApi.isConnected();

// If onDownloadProgress is not explicitly re-exposed here,
// Comlink will throw an error and claim the callback
Expand Down
Loading

0 comments on commit 11e1fc8

Please sign in to comment.