Skip to content

fix: seven correctness and lifecycle findings from the bidi-streaming review - #5

Merged
qcodr merged 6 commits into
mainfrom
fix/post-merge-review-findings
Aug 1, 2026
Merged

fix: seven correctness and lifecycle findings from the bidi-streaming review#5
qcodr merged 6 commits into
mainfrom
fix/post-merge-review-findings

Conversation

@qcodr

@qcodr qcodr commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Post-merge review of fix/bidi-streaming-cancellation (merged as #4) surfaced seven issues that are all still live on main. This fixes them. No behaviour change for the happy path — every default is preserved.

Sev Fix
HIGH runAsync() was missing run()'s non-terminal failure branch
MED Cancellation fanned out to every call ever issued, not just in-flight ones
MED amphp connection/idle ceilings were hardcoded and unconfigurable
MED Forked workers returned from listen() instead of exiting
MED Child reaping was not in a finally
MED Proposed-run stash was never released
LOW Run-completion ack was decoded as a Notification

Details

runAsync() dropped the retry-policy path. It carried only the TerminalException and SerializationException arms of run(), missing catch (Throwable) => handleRunFailure(). A transient failure inside the closure escaped runAsync() synchronously, so the caller never got the DurableFuture the interface promises — the "compose it concurrently" contract broke exactly in the failure case. It also took no RunOptions, so per-run retry policies were unavailable for async runs. Both now share one runFuture() body; handleRunFailure() returns the future rather than awaiting internally. This also removes ~40 lines already duplicated once.

Cancellation cancelled finished children. trackedInvocationIdCompletions only ever grew, so a workflow making 500 sequential calls and then being cancelled appended 500 SendSignalCommands — 499 targeting invocations that had returned. It becomes a invocationIdCompletionId => resultCompletionId map, and raiseCancellation() skips any call whose result already landed. Emitted commands are now proportional to in-flight children, not to invocation length.

Server limits are now a value object. 100 000 connections / per-IP / concurrent and 3600 s idle are correct for the Restate runtime — one trusted peer, many long-lived deliberately idle streams from one IP, which amphp's defaults reject — but they are not a general hardening posture, and the endpoint starts without identity verification by default. Same values, now in ServerLimits, passable to the constructor, each validated positive. README says when to tighten.

Worker lifecycle. Children returned from listen(), so all post-listen() code, shutdown functions and destructors ran once per worker; they now exit(0). The reap loop sat after the parent's runServer() with no try/finally, so a parent that threw left children holding the port via SO_REUSEPORT; it is now in a finally.

Proposal stash. sysEnd(), notifyError() and writeSuspension() route through markClosed(), which clears pendingRunResults. A late ProposeRunCompletionAck can no longer promote a stale value into the completion table.

Ack decoding. ProposeRunCompletionAck gets its own decoder instead of borrowing Notification::decode(); the two are different message types that merely agreed on field 1.

Also gitignores graphify-out/ (13 MB generated tree, one git add -A from being committed).

Test plan

  • composer test:unit529 passing (was 511); 18 new across 5 files
  • Each new test verified RED before its fix: cancellation produced ['inv-A', 'inv-B'] where ['inv-B'] is correct, and the post-sysEnd() ack resurrected a completion
  • composer stan — PHPStan level max over src + tests, no errors, no baseline entries or ignores added
  • composer sast — Psalm taint analysis, no errors
  • composer cs — 0 / 247
  • All three commits independently green (514 / 522 / 529), so the branch bisects
  • Conformance suite (make conformance) — needs Docker + AVX2 + JDK 21; label this PR conformance to run it in CI

Not covered

The multi-worker fork path (runServer, detectWorkers) still has no automated test — exercising it would fork the PHPUnit process. It needs an integration/smoke test, which is out of scope here.

qcodr added 3 commits August 1, 2026 09:42
runAsync() carried only the TerminalException and SerializationException arms
of run(), missing its `catch (Throwable) => handleRunFailure()` branch. Two
consequences:

  - a non-terminal failure inside the closure escaped runAsync() synchronously,
    so the caller never received the DurableFuture the interface promises --
    the "control returns immediately so the run can be composed concurrently"
    contract broke precisely in the failure case;
  - runAsync() took no RunOptions, so a per-run retry policy (attempt counting,
    give-up-as-terminal) was unavailable for async runs.

Both now share a single runFuture() body: it journals the RunCommand, executes
the closure at most once and proposes the result, returning the future. run()
awaits it, runAsync() hands it back. handleRunFailure() returns that future
instead of awaiting internally, so the exhausted-attempts branch resolves
identically for both callers.

Sharing the body also removes ~40 lines that had already been duplicated once
and were free to drift again.
Implicit cancellation sent the built-in CANCEL signal to every call the handler
had ever issued whose invocation id was known -- including calls that returned
long ago. A workflow making 500 sequential calls and then being cancelled
appended 500 SendSignalCommands, 499 of them targeting finished invocations, so
the emitted commands grew with the invocation's length rather than with the
number of children actually running.

trackedInvocationIdCompletions (list<int>) becomes trackedCallCompletions
(invocationId completion id => result completion id). raiseCancellation() now
skips any call whose result completion has already landed: a finished call has
nothing left to tear down.

Also releases the proposed-run stash when the slice ends. sysEnd(), notifyError()
and writeSuspension() route through a new markClosed() that clears
pendingRunResults, so a ProposeRunCompletionAck arriving after the invocation
ended cannot promote a stale value into the completion table, and a VM whose acks
never arrive does not hold the values for its lifetime.

Finally, the ack is decoded with its own ProposeRunCompletionAck::decode()
instead of Notification::decode(). The two are different message types that
happened to agree on field 1; a dedicated decoder keeps them from being coupled
through that coincidence. The JournalBuilder docblock claiming the SDK ignores
the ack was stale -- over streaming the ack IS the resolution.
Three issues in the bidi streaming host.

Limits were hardcoded constants: 100_000 connections, 100_000 per IP, 100_000
concurrent, 3600s stream and connection idle. The values are right for the
Restate runtime -- a single trusted peer holding many long-lived, deliberately
idle streams from one IP, which amphp's own defaults reject -- but they are not
a general hardening posture, and the endpoint also starts without identity
verification. With the per-IP ceiling that high, anything else reaching the
endpoint can hold many idle connections for an hour each. They move to a
ServerLimits value object with the same defaults, passable to the constructor,
every ceiling validated positive. README documents when to tighten them.

Forked workers returned from listen() instead of exiting, so every statement
after listen(), plus each registered shutdown function and destructor, ran once
per worker. They now exit(0).

The reap loop ran after the parent's own runServer() with no try/finally: if the
parent threw (bind failure, driver error) the children survived it, still holding
the port through SO_REUSEPORT. It is now in a finally, extracted to
stopWorkers().

Also gitignores graphify-out/, a 13 MB generated tree that was one `git add -A`
away from being committed.
@codacy-production

codacy-production Bot commented Aug 1, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 3 medium

Alerts:
⚠ 3 issues (≤ 0 issues of at least minor severity)

Results:
3 new issues

Category Results
ErrorProne 3 medium

View in Codacy

🟢 Metrics 27 complexity · 0 duplication

Metric Results
Complexity 27
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Comment thread src/Server/AmpStreamingServer.php
qcodr added 3 commits August 1, 2026 09:51
CI resolves dependencies fresh (composer.lock is gitignored for a library), so it
picked up phpstan/phpstan-phpunit 2.0.18 while local vendor/ still had 2.0.16.
The newer release reports `staticMethod.alreadyNarrowedType` for
assertInstanceOf() calls whose subject already has that declared return type --
12 of them, 11 pre-dating this branch. It is a latent failure on main, not
something this branch introduced, but the PR has to be green.

The assertions were tautologies: the return types already guarantee them. Rather
than suppress the rule, each is replaced by something the type system does NOT
know:

  - the async-call variants now assert each future owns a distinct result
    completion id (2, 4, 6), which is what "three separate calls were journaled"
    actually means;
  - the call-handle variants assert the invocation-id/result id pairs, pinning
    both the allocation order and that no ids are shared;
  - context metadata asserts random() is one seeded instance per invocation while
    logger() is rebuilt per call so its replay state cannot go stale;
  - the awakeable and runAsync cases keep only their meaningful assertion, plus
    an isReady() check showing a proposed run stays unresolved until the runtime
    acks it.

Two tests that previously asserted nothing beyond "the return type is the return
type" now carry real coverage.
The child branch called runServer() and then exit(0), but the call was not
guarded. If a child's runServer() threw (bind failure, driver error) the
exception unwound out of listen() in that process, running its destructors and
shutdown functions -- exactly the lifecycle contamination exit(0) was added to
prevent, and the same gap the parent path had before its try/finally.

The child now always exits: cleanly on a normal stop, or with a message on
STDERR and status 1 when it could not serve, so a supervisor can tell a failed
worker from a clean shutdown.

Reported by gitar-bot on #5.
The badge was never activated -- a commented-out block with a literal
CODACY_PROJECT_ID placeholder. Codacy is not wired into the repo at all (no
workflow, no config); it analysed pushes through a GitHub App installed on the
account. With that integration going away, the placeholder is dead weight.
@gitar-bot

gitar-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 1 resolved / 1 findings

Fixes seven correctness and lifecycle issues from the bidi-streaming review, including worker lifecycle handling and retry policies. No issues found.

✅ 1 resolved
Edge Case: Child worker exit not guarded if runServer throws

📄 src/Server/AmpStreamingServer.php:131-137
In the fork branch, $this->runServer(...) is followed by exit(0) but is not wrapped in try/finally, unlike the parent path just below it. If a child's runServer throws (bind failure, driver error), the exception unwinds out of listen() in the child process, running its destructors and shutdown functions — the exact lifecycle contamination the exit(0) change is meant to prevent. Wrap the child call so it always exits: try { $this->runServer(...); } finally { exit(0); } (or catch, log to STDERR, and exit(1)).

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@qcodr
qcodr merged commit 26d4f05 into main Aug 1, 2026
7 of 8 checks passed
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