Skip to content

Fix progressive performance degradation in playground.run()#3235

Draft
JanJakes wants to merge 3 commits intotrunkfrom
fix/playground-run-performance-degradation
Draft

Fix progressive performance degradation in playground.run()#3235
JanJakes wants to merge 3 commits intotrunkfrom
fix/playground-run-performance-degradation

Conversation

@JanJakes
Copy link
Member

@JanJakes JanJakes commented Feb 2, 2026

Motivation for the change, related issues

Repeated calls to playground.run() exhibited progressive performance degradation. The first batch of calls would execute in ~1ms, but after many calls the execution time would increase to 10-11ms — an 11.76x slowdown.

This affected browser-based usage where the same PHP instance is reused across requests via SinglePHPInstanceManager.

Implementation details

Three sources of resource accumulation were identified and fixed:

1. Comlink proxy accumulation (comlink-sync.ts)

Each property access on a Comlink proxy created a new proxy object registered with FinalizationRegistry. These proxies accumulated over time. Fixed by adding a WeakMap-based cache that reuses existing proxies for the same endpoint and path combination.

2. Stream reader cleanup (php-response.ts)

The streamToText() and streamToBytes() functions didn't properly release reader locks in all code paths. Fixed by adding try/finally blocks with reader.releaseLock().

3. Worker listener accumulation (php-worker.ts)

When using SinglePHPInstanceManager, the same PHP instance is reused across multiple run() calls. Previously, registerWorkerListeners() added new event listeners on every call, causing O(n) listener accumulation where each event fired N times for N calls. Fixed by tracking registered instances in a WeakSet to prevent duplicate registration.

Testing Instructions (or ideally a Blueprint)

Run this in the browser console on the Playground website:

(async () => {
    const playground = window.playground;
    
    // Warmup
    for (let i = 0; i < 5; i++) {
        await playground.run({ code: '<?php echo "warmup";' });
    }
    
    const times = [];
    for (let i = 0; i < 100; i++) {
        const start = performance.now();
        await playground.run({ code: '<?php echo "test";' });
        times.push(performance.now() - start);
    }
    
    const avg = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
    const first10 = avg(times.slice(0, 10));
    const last10 = avg(times.slice(-10));
    
    console.log('First 10 avg:', first10.toFixed(2) + 'ms');
    console.log('Last 10 avg:', last10.toFixed(2) + 'ms');
    console.log('Ratio:', (last10 / first10).toFixed(2) + 'x');
})();

Before: Ratio ~11.76x (gets slower)
After: Ratio ~0.7x (gets faster due to JIT — no degradation)

Each property access on a Comlink proxy created a new proxy object
registered with FinalizationRegistry. Over many calls, these proxies
accumulated and caused progressive performance degradation.

This adds a WeakMap-based cache that reuses existing proxies for the
same endpoint and path combination. The cache is automatically cleaned
up when the endpoint is garbage collected, and explicitly cleared when
the proxy is released.
The streamToText() and streamToBytes() functions didn't properly
release reader locks in all code paths. This adds try/finally blocks
with reader.releaseLock() to ensure proper cleanup regardless of
how the function exits.
When using SinglePHPInstanceManager, the same PHP instance is reused
across multiple run() calls. Previously, registerWorkerListeners()
would add new event listeners on every run(), causing O(n) listener
accumulation where each event fired N times for N calls.

This adds a WeakSet to track PHP instances that have already had
listeners registered, preventing duplicate registration and
eliminating the progressive performance degradation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant