Skip to content

Persist pooled PHP writes to saved Playground sites - #4240

Closed
ashfame wants to merge 12 commits into
trunkfrom
agent/fix-opfs-request-flush-export-barrier
Closed

ashfame wants to merge 12 commits into
trunkfrom
agent/fix-opfs-request-flush-export-barrier

Conversation

@ashfame

@ashfame ashfame commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

What

Ensure filesystem changes made by pooled PHP instances are flushed to OPFS for every saved Playground site. Without this trigger, recent WordPress changes could remain queued in memory instead of reaching the site's persisted files.

Reported by @fellyph in this comment with a video showing recent changes missing after using Playground's regular Download as .zip and re-import flow.

The regular Playground ZIP export already works through the active PHP-WASM VFS via zipWpContent(). This PR fixes the persistence lifecycle exposed by that flow; it does not change export code or test the standalone api.html exportSavedSiteAsZip() API.

Root cause

Playground web uses one Web Worker per site, but that worker's PHPRequestHandler maintains a pool of PHP-WASM instances and allows two concurrent instances by default. Secondary instances proxy shared filesystem paths into the primary instance through PROXYFS.

The OPFS journal is mounted on the primary PHP instance and listens for its request.end event. A request handled by a secondary instance writes through the primary filesystem, so the journal captures those operations, but its request.end event remains on the secondary instance. The request could therefore finish while the captured operations remained queued in memory until a later lifecycle event happened to flush them.

Fix

Listen for request.end on secondary PHP instances and trigger a flush for every active OPFS mount after their proxied writes finish. The existing journal flush is single-flight and drains entries added while a flush is running in subsequent batches.

Regression test

The new Chromium E2E test creates an autosaved site as its fixture, keeps the primary PHP instance busy so a concurrent request runs on a pooled instance, writes a marker file from that pooled request, confirms the marker exists in the live VFS, and then verifies that the same contents reach the site's underlying OPFS directory. Although the fixture is autosaved, the implementation covers every active OPFS mount and therefore all saved sites.

The test was explicitly run on both sides of the implementation change:

  • Without the code change (b4e0945a6), it failed at the OPFS assertion: the live marker contained the expected text, but OPFS returned undefined.
  • With the code change (a5746e958), the same focused test passed.

This fail-before/pass-after result confirms that the test covers the missing persistence trigger rather than merely restating the implementation.

Validation

  • npm exec -- nx build playground-website
  • npm exec -- nx run-many -t typecheck lint -p playground-remote playground-website --parallel=3
  • Focused Chromium pooled-PHP OPFS regression: failed without the implementation and passed with it

@ashfame
ashfame marked this pull request as ready for review August 11, 2026 19:12
Copilot AI lite review requested due to automatic review settings August 11, 2026 19:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Fixes stale saved-site OPFS ZIP exports after pooled PHP requests by ensuring all proxied writes are flushed and by adding a Web Locks–based durability boundary so exports wait for journal replay to settle.

Changes:

  • Adds a pooled-PHP OPFS regression test and related assertions for durability lock propagation.
  • Flushes all active OPFS mount journals when a secondary (pooled) PHP request ends.
  • Introduces an origin-wide durability lock name, holding it exclusively during journal replay and shared during ZIP export traversal.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/playground/website/src/lib/state/redux/slice-clients.ts Updates stored OPFS mount descriptor typing to align with remote MountDescriptor and new durability option.
packages/playground/website/src/lib/state/redux/persist-temporary-site.ts Passes a per-site durability lock name into OPFS mount options.
packages/playground/website/src/lib/state/redux/persist-temporary-site.spec.ts Verifies durabilityLockName is forwarded when mounting OPFS.
packages/playground/website/src/lib/state/redux/boot-site-client.ts Passes durability lock name into OPFS mounts when booting a saved OPFS site.
packages/playground/website/src/lib/state/redux/boot-site-client.spec.ts Updates mocks/assertions for durability lock; removes legacy OPFS metadata-path test.
packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts Wraps ZIP exports in a shared Web Lock to await earlier exclusive journal replays.
packages/playground/website/src/lib/state/opfs/opfs-site-storage.spec.ts Adds unit test ensuring export traversal waits for the shared durability lock.
packages/playground/website/src/lib/state/opfs/opfs-site-path.ts Introduces getOpfsSiteDurabilityLockName() to centralize lock naming.
packages/playground/website/playwright/e2e/opfs.spec.ts Adds Chromium-only E2E regressions for pooled-request flush + export durability.
packages/playground/remote/src/lib/playground-worker-endpoint.ts Flushes all OPFS mounts on secondary PHP request.end; plumbs through durabilityLockName.
packages/php-wasm/web/src/lib/directory-handle-mount.ts Takes durabilityLockName and acquires an exclusive Web Lock while draining the OPFS journal.
packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts Adds unit test verifying exclusive lock is held during flush.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts Outdated
Comment thread packages/playground/website/src/lib/state/opfs/opfs-site-storage.spec.ts Outdated
Comment thread packages/playground/website/playwright/e2e/opfs.spec.ts Outdated
@ashfame ashfame changed the title Fix stale OPFS exports after pooled PHP requests Prevent saved-site exports from missing pooled PHP writes Aug 12, 2026

@brandonpayton brandonpayton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@ashfame based on the PR description and the fact that we only have a single PHP-WASM Worker in Playground web, I wonder:

Is the deeper issue the fact that our export logic does not respect VFS ownership of OPFS?

This is what we'd do in a regular operating system. We'd ask the operating system for the data instead of directly reading bytes from a raw block device. We'd do that because the operating system is in charge of caching reads and writes and because skipping that layer would expose us to the possibility of incomplete or corrupted data.

We may still need some kind of locking for this, but I think everything could be simpler and more encapsulated if we:

  1. Change export to work through the PHP-WASM VFS abstraction.
  2. Make sure additional requests to the PHP-WASM cannot write to OPFS during a flush if flush is async. If the actual flush is not async, we may not need a lock at all, though we'd need to be very careful to prove this and to make it hard to be accidentally async in the future.

@ashfame ashfame changed the title Prevent saved-site exports from missing pooled PHP writes Persist pooled PHP writes to autosaved Playground sites Aug 12, 2026
@ashfame ashfame changed the title Persist pooled PHP writes to autosaved Playground sites Persist pooled PHP writes to saved Playground sites Aug 12, 2026
@ashfame

ashfame commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

@brandonpayton Thanks — the previous PR description conflated two export APIs. I’ve corrected it and removed the unrelated api.html saved-site export changes and tests.

The regular Download as .zip flow already follows your first suggestion: it calls zipWpContent(playground), which runs ZipArchive against the active PHP-WASM VFS.

Playground web uses one Web Worker per site, but that worker’s PHPRequestHandler maintains a pool of PHP-WASM instances, with up to two instances by default. Secondary instances share the primary instance’s filesystem through PROXYFS.

The actual gap is lifecycle propagation: writes from a secondary instance reach the primary VFS and are captured by its OPFS journal, but request.end fires on the secondary instance. The primary-mounted journal therefore did not receive the request-end flush trigger, leaving those operations queued until a later event.

The remaining change triggers the active OPFS journal flushes when a secondary request ends. It does not modify export or add export locking. The existing flush implementation is single-flight and drains journal entries added during an in-progress flush in subsequent batches.

The regression test forces a request onto the secondary instance, confirms its write is visible in the live VFS, and then verifies that it reaches OPFS. We also mutation-tested it: it fails without the implementation change and passes with it.

Does that address your concern?

@brandonpayton brandonpayton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you, @ashfame! This is much simpler than the previous iteration.

That said, I think we need to adjust the approach to more directly address our proxying and journaling relationships instead of adding special, secondary-only behavior in the worker.

I left some notes on the test as well.

});
await new Promise((resolve) => setTimeout(resolve, 50));
const pooledRequest = playground.run({
code: `<?php usleep(750000); file_put_contents(${JSON.stringify(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What is the purpose of this sleep?

async ({ markerPath, markerContents }) => {
const playground = (window as any).playgroundSites.getClient();
const primaryRequest = playground.run({
code: '<?php usleep(500000);',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's comment on the purpose of this sleep.

},
{ markerPath, markerContents }
);
expect(liveMarkerContents).toBe(markerContents);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's say what we are proving with this assertion.

);
expect(liveMarkerContents).toBe(markerContents);

await expect

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm finding myself confused by what we are proving here. Above, we try to make sure a second request is handled by a secondary PHP instance that writes a file to an OPFS mount.

Does that mean our check here is to ensure the expected changes are flushed to OPFS after the request completes?

I feel like this test is probably exercising all the right things, but can we really prove that? Can we prove we are using a secondary instance for the second request? Is it possible there are races within the time frames when the request promise is resolved, when we continue from awaiting the second request, and when we are doing the polling below?

Is there a way we could more explicitly prove that writes to a secondary PHP instance are 100% flushed to OPFS before any other PHP instance runs?

Comment on lines +249 to +265
/**
* 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
);
});
}
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this is working at the wrong level of abstraction. It may solve the problem, but shouldn't the solution be in the code that configures journaling and only listens to request.end for a single PHP instance. I think we probably need to consider that abstraction and whether it could do one of the following:

  • be aware of multiple PHP instances
  • use a better trigger than request.end on a single PHP instance

The truth is that Playground web creates a single PHP instance to own the FS and expects secondary PHP instances to delegate to it.

Would it makes sense for this truth to be reflected in an event delegated from the secondary PHP instances to the primary PHP instance, like proxyfs.request.end? Maybe such an event from the primary PHP instance could be wired within proxyFileSystem().

If the primary PHP notified when a secondary PHP-instance-with-proxed-FS ended its request, then journalFSEventsToOpfs() could subscribe to that event as well.

We could complain that this exposes system internals, but at least it would be a solution that explicitly coupled to the internal reality of proxying FS. The alternative here is to split the ownership of OPFS flushes between multiple modules, with this module taking some responsibility from journalFSEventsToOpfs().

What do you think?

@ashfame

ashfame commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

@brandonpayton Great feedback! Closing this PR, superseded by #4260

@ashfame ashfame closed this Aug 13, 2026
@ashfame
ashfame deleted the agent/fix-opfs-request-flush-export-barrier branch August 13, 2026 21:13
ashfame added a commit that referenced this pull request Aug 14, 2026
## What

Persist filesystem changes made by pooled PHP instances to autosaved
Playground sites after the request completes.

This supersedes #4240 with the architecture Brandon proposed: PROXYFS
propagates replica request completion to the filesystem owner, while the
OPFS journal remains solely responsible for deciding when to flush.

## Root cause

Playground web uses a pool of PHP instances. Secondary instances access
shared paths through PROXYFS backed by the primary PHP instance. Their
writes are captured by the primary instance's OPFS journal, but
`request.end` is emitted only by the secondary instance. The primary
journal therefore has no completion signal for the proxied request, and
recent changes can remain queued in memory instead of reaching OPFS.

## Deterministic regression proof

The Chromium test exercises the original saved-site symptom instead of
asserting any event or flush machinery introduced by this PR:

- It writes a unique sentinel outside the proxied mounts, so only the
primary PHP runtime can see it.
- The first request creates a file barrier and remains blocked; the
second request then creates its own barrier and remains blocked. The
test observes each barrier instead of sleeping to guess when either
request started, then overwrites the same file to release that request.
- The test releases and fully awaits the first request before allowing
the second request to write the marker, eliminating a later primary
`request.end` that could accidentally flush it.
- The completed responses prove the requests ran on `primary` and
`secondary`, respectively.
- A public `readFileAsText()` call proves the marker reached the shared
live VFS, then an independent read of the autosaved site's real OPFS
directory checks the persisted bytes.

At red SHA
[`4a5116926`](4a51169),
the PR contained only this test. The Chromium storage job reached the
final OPFS assertion on all four attempts and consistently received
`undefined` instead of the marker contents; all other 60 tests passed.
See the [expected failed CI
job](https://github.com/WordPress/wordpress-playground/actions/runs/31738099889/job/94574529357).

The regression test at the fixed SHA is byte-for-byte identical to the
red version.

## Implementation

- `proxyFileSystem()` forwards a replica's `request.end` as
`proxyfs.request.end` on the filesystem owner.
- `journalFSEventsToOpfs()` handles that lifecycle event through its
existing single-flight background flush, alongside direct `request.end`
and `filesystem.write` triggers.
- Journal teardown removes the new listener with its existing listeners.

This keeps pool-specific behavior out of Playground's worker and keeps
OPFS flush ownership inside the journaling abstraction. It does not
change export code or introduce an export lock.

## Validation

- Red CI: [Chromium storage
job](https://github.com/WordPress/wordpress-playground/actions/runs/31738099889/job/94574529357)
— the regression test alone fails at the persisted-bytes assertion on
all four attempts.
- Local green: universal tests 184 passed / 4 skipped; web tests 60
passed; website rerun 377 passed; affected typechecks and lints passed.
- Green CI: [full CI
run](https://github.com/WordPress/wordpress-playground/actions/runs/31740060392)
and [Chromium storage
job](https://github.com/WordPress/wordpress-playground/actions/runs/31740060392/job/94581003107)
passed on fixed SHA
[`b9013b91e`](b9013b9).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants