Skip to content

fix(serializer): require explicit opt-in to decode closure payloads - #7

Merged
abnegate merged 2 commits into
mainfrom
fix/opt-in-trusted-closure-decoding
Sep 29, 2026
Merged

abnegate merged 2 commits into
mainfrom
fix/opt-in-trusted-closure-decoding

Conversation

@abnegate

Copy link
Copy Markdown
Member

Why

Serializer::unserialize() decoded every payload carrying the closure prefix through opis/closure. opis/closure rebuilds the objects inside such a payload by reflection (newInstanceWithoutConstructor() + __unserialize()), which PHP's allowed_classes option does not govern, and restricting allowed_classes breaks closure round-trips altogether. A caller decoding data it does not fully control could therefore have arbitrary classes instantiated, whatever options it passed.

What

  • Serializer::unserialize() refuses closure payloads with Utopia\Async\Exception\Serialization and keeps the allowed_classes => false default for plain data.
  • New Serializer::unserializeTrusted() keeps the previous behaviour, for payloads from a trusted channel (this process or its own workers).
  • The Swoole process pool, which exchanges closures only with its own forked workers, uses unserializeTrusted() on both ends.
  • CHANGELOG.md and UPGRADE.md for 0.2.0 (breaking default), README section on serialization.

Tests

tests/Unit/SerializerTest.php:

  • testUnserializeRefusesClosurePayloadByDefault: a closure payload carrying an object whose __unserialize() counts restorations is refused, and nothing is instantiated (fails on main: the payload was decoded).
  • testUnserializeRefusesClosurePayloadWithAllowedClasses: caller options do not enable closure decoding (fails on main).
  • testUnserializeTrustedRestoresClosurePayload / testUnserializeTrustedDecodesPlainPayloadWithoutClasses: the opt-in round-trips closures and objects, and plain payloads keep allowed_classes => false.
  • Existing closure round-trip tests now use unserializeTrusted().

Locally: SerializerTest.php (23 tests) and tests/E2e/Parallel/Swoole/ProcessTest.php (29 tests, exercises the pool's trusted channel) pass.

🤖 Generated with Claude Code

opis/closure rebuilds the objects inside a closure payload by reflection
(newInstanceWithoutConstructor + __unserialize), which PHP's allowed_classes
option does not govern, and restricting allowed_classes breaks closure
round-trips altogether. Serializer::unserialize() decoded any payload with
the closure prefix, so a caller decoding untrusted data could have arbitrary
classes instantiated.

unserialize() now refuses closure payloads with a Serialization exception
and keeps the allowed_classes => false default for plain data. The new
unserializeTrusted() keeps the previous behaviour for payloads from a
trusted channel; the Swoole process pool, which exchanges closures only with
its own forked workers, uses it. Breaking default, hence 0.2.0 with
CHANGELOG and UPGRADE notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Benchmark Results

Swoole Adapters (Sync, Swoole Thread, Swoole Process, Amp, React)

╔══════════════════════════════════════════════════════════════════════════════╗
║                    Parallel Adapter Benchmark                                ║
╚══════════════════════════════════════════════════════════════════════════════╝

System Info:
  PHP Version: 8.4.26
  Swoole Version: 6.1.3
  CPU Cores: 4
  Iterations: 10 per test
  Load: 50% (workload intensity)

Detected adapters:
  [x] Sync
  [x] Swoole Thread
  [x] Swoole Process
  [x] Amp
  [x] React
  [x] ext-parallel

┌──────────────────────────────────────────────────────────────────────────────┐
│ CPU-Intensive Workloads                                                      │
└──────────────────────────────────────────────────────────────────────────────┘

  Prime calculation (4 tasks, primes up to 200000)
--------------------------------------------------------------------------------

ext-parallel Adapter (Sync, Amp, React, ext-parallel)

╔══════════════════════════════════════════════════════════════════════════════╗
║                    Parallel Adapter Benchmark                                ║
╚══════════════════════════════════════════════════════════════════════════════╝

System Info:
  PHP Version: 8.4.26
  CPU Cores: 4
  Iterations: 10 per test
  Load: 50% (workload intensity)

Detected adapters:
  [x] Sync
  [ ] Swoole Thread
  [ ] Swoole Process
  [x] Amp
  [x] React
  [x] ext-parallel

┌──────────────────────────────────────────────────────────────────────────────┐
│ CPU-Intensive Workloads                                                      │
└──────────────────────────────────────────────────────────────────────────────┘

  Prime calculation (4 tasks, primes up to 200000)
--------------------------------------------------------------------------------
  Sync:             0.285s (std: 0.016s, range: 0.273-0.325s)
  Amp:              0.166s (std: 0.041s, range: 0.125-0.272s)  1.72x speedup
  React:            0.287s (std: 0.032s, range: 0.209-0.316s)  0.99x speedup
  ext-parallel:     0.138s (std: 0.011s, range: 0.131-0.161s)  2.07x speedup
  Winner: ext-parallel (51.7% faster than sync)

  Matrix multiply (4 tasks, 200x200)
--------------------------------------------------------------------------------
  Sync:             0.736s (std: 0.022s, range: 0.719-0.793s)
  Amp:              0.351s (std: 0.036s, range: 0.336-0.452s)  2.10x speedup
  React:            0.457s (std: 0.043s, range: 0.398-0.502s)  1.61x speedup
  ext-parallel:     0.345s (std: 0.006s, range: 0.343-0.362s)  2.14x speedup
  Winner: ext-parallel (53.2% faster than sync)

┌──────────────────────────────────────────────────────────────────────────────┐
│ I/O-Simulated Workloads                                                      │
└──────────────────────────────────────────────────────────────────────────────┘

  Sleep tasks (4 tasks, 50ms each)
--------------------------------------------------------------------------------
  Sync:             0.200s (std: 0.000s, range: 0.200-0.200s)
  Amp:              0.064s (std: 0.041s, range: 0.051-0.181s)  3.11x speedup
  React:            0.113s (std: 0.031s, range: 0.102-0.202s)  1.77x speedup
  ext-parallel:     0.052s (std: 0.006s, range: 0.050-0.069s)  3.83x speedup
  Winner: ext-parallel (73.9% faster than sync)

  Mixed workload (4 tasks)
--------------------------------------------------------------------------------
  Sync:             0.125s (std: 0.001s, range: 0.125-0.128s)
  Amp:              0.061s (std: 0.032s, range: 0.051-0.151s)  2.06x speedup
  React:            0.102s (std: 0.003s, range: 0.100-0.111s)  1.23x speedup
  ext-parallel:     0.056s (std: 0.007s, range: 0.050-0.068s)  2.22x speedup
  Winner: ext-parallel (55.0% faster than sync)

┌──────────────────────────────────────────────────────────────────────────────┐
│ Scaling Benchmarks                                                           │
└──────────────────────────────────────────────────────────────────────────────┘

  Scaling test (1 tasks)
--------------------------------------------------------------------------------
  Sync:             0.029s (std: 0.000s, range: 0.029-0.029s)
  Amp:              0.031s (std: 0.014s, range: 0.026-0.071s)  0.93x speedup
  React:            0.062s (std: 0.001s, range: 0.061-0.063s)  0.47x speedup
  ext-parallel:     0.032s (std: 0.006s, range: 0.030-0.049s)  0.90x speedup
  Winner: Amp (-7.4% faster than sync)

  Scaling test (2 tasks)
--------------------------------------------------------------------------------
  Sync:             0.042s (std: 0.004s, range: 0.039-0.052s)
  Amp:              0.035s (std: 0.022s, range: 0.026-0.097s)  1.20x speedup
  React:            0.063s (std: 0.001s, range: 0.062-0.064s)  0.67x speedup
  ext-parallel:     0.032s (std: 0.006s, range: 0.030-0.048s)  1.31x speedup
  Winner: ext-parallel (23.6% faster than sync)

  Scaling test (4 tasks)
--------------------------------------------------------------------------------
  Sync:             0.111s (std: 0.001s, range: 0.107-0.111s)
  Amp:              0.058s (std: 0.027s, range: 0.037-0.133s)  1.91x speedup
  React:            0.119s (std: 0.041s, range: 0.097-0.198s)  0.93x speedup
  ext-parallel:     0.043s (std: 0.005s, range: 0.041-0.058s)  2.59x speedup
  Winner: ext-parallel (61.4% faster than sync)

  Scaling test (8 tasks)
--------------------------------------------------------------------------------
  Sync:             0.166s (std: 0.003s, range: 0.160-0.168s)
  Amp:              0.102s (std: 0.030s, range: 0.075-0.181s)  1.63x speedup
  React:            0.206s (std: 0.012s, range: 0.194-0.224s)  0.80x speedup
  ext-parallel:     0.084s (std: 0.006s, range: 0.082-0.100s)  1.98x speedup
  Winner: ext-parallel (49.4% faster than sync)
   16 tasks: Sync=0.345s, Amp=0.178s (1.9x), React=0.415s (0.8x), parallel=0.166s (2.1x)
   32 tasks: Sync=0.826s, Amp=0.354s (2.3x), React=0.809s (1.0x), parallel=0.330s (2.5x)

┌──────────────────────────────────────────────────────────────────────────────┐
│ Summary                                                                      │
└──────────────────────────────────────────────────────────────────────────────┘

  Adapter             Wins  Avg Speedup  Max Speedup
----------------------------------------------------
  Amp                    1        1.89x        3.11x
  React                  0        1.03x        1.77x
  ext-parallel           9        2.16x        3.83x

  Recommendation: ext-parallel for best overall performance.
  Theoretical max speedup: 4x (limited by CPU cores)


Benchmarks run with 10 iterations on GitHub Actions runner

@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk]

The PR appears safe to merge; no outstanding blocking findings remain.

Summary

The PR makes closure-payload decoding an explicit trusted-channel opt-in while retaining plain-data decoding defaults.

  • Updates both ends of the Swoole worker channel to use trusted decoding.
  • Documents the breaking change and adds serialization tests.

Reviews (2) · Last reviewed commit: "test(serializer): assert refusal by outc..."

Comment thread src/Serializer.php
Comment thread tests/Unit/SerializerTest.php Outdated
The refusal test now checks only that the payload is refused and that
nothing inside it is instantiated, rather than pinning the exception
message or the restoration count on the trusted path. UPGRADE notes that
Serialization is not a RuntimeException, so callers that caught only
RuntimeException around unserialize() know to handle a refused payload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abnegate
abnegate merged commit c7925a2 into main Sep 29, 2026
19 checks passed
@abnegate
abnegate deleted the fix/opt-in-trusted-closure-decoding branch September 29, 2026 14:09
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