Skip to content

Reuse one activity lease helper per Worker process #326

Closed

Description

@rmcdaniel

External performance report, topic 1. The reported timings and proposed fixes have not been independently reproduced. The supplied patches are reference drafts for review and current-source qualification.

Problem

Each activity starts a fresh PHP process to maintain its lease. Before the
activity can run, that process:

  • builds a Client, which costs a GET /api/cluster/info;
  • sends a Worker heartbeat;
  • sends a lease renewal.

It then polls its pipe every 10 ms, and takes up to 250 ms to stop.

On a Server that serializes transitions, each heartbeat and each renewal is
a locked transition. Across the reported three-repetition series (3,000 activities total), the worker sent:

  • 3,018 cluster/info calls;
  • 3,189 worker heartbeats;
  • 3,000 activity lease renewals.

Reported source behavior (main afa5a95)

  • src/Worker/ActivityLeaseMaintainer.php starts one
    src/Worker/ActivityLeaseProcess.php per activity.
  • The process proves its Client and renews before the activity callback
    runs.

Proposal

  • A Worker keeps one helper and hands it each activity in turn.
  • The helper proves its Client with a renewal on its first activity
    only. After that, a grant the poll delivered moments ago runs to its
    usual renewal point.
  • Presence is sent when the Worker's own heartbeat falls due, never later.
  • The helper wakes on input instead of polling its pipe.
  • A helper that can't confirm the end of an activity is stopped, as today.
  • A forked callback never talks to its parent's helper.
  • A helper that died is replaced before the next activity needs it.

Reported benchmark evidence

Reporter-supplied cumulative measurements, not an isolated-effect or independently verified performance claim: 200 sequentially started workflows, five sequential 20ms activities or five local activities each, six PHP worker processes, mean of three repetitions, one machine, fresh SQLite/MySQL8.0/PostgreSQL16 databases with default durability. "Five" and "local" below mean last-close wall time in seconds; S/M/P means SQLite/MySQL/PostgreSQL. Preserve regressions and noise in the reported results.

five local
slowest backend 51.70 → 40.52 9.68 → 9.44
S / M / P 40.52 / 37.98 / 24.99 —

Requests per five-steps race fell from 47.6k to 33.4k on MySQL.

Acceptance

  • One helper process serves every activity of a Worker. A test runs
    several activities and counts the processes.
  • No cluster/info call is made per activity, and no renewal is sent
    before a freshly granted activity runs.
  • A helper killed between activities is replaced, and the next activity
    keeps its lease.
  • A forked activity callback doesn't write to the parent's helper.
  • tests/WorkerActivityLeaseMaintenanceTest.php covers the above.

Implementation constraints

Preserve lease supervision and fork/shutdown behavior while reducing helper process creation.

Dependencies

No new producer dependency is required for source investigation.

Related existing work: #137.

Draft patches and validation boundary

Current main was independently read as afa5a95e3116cd69c731c821f00e8223d42f7ff6. The supplied draft series targets afa5a95e3116cd69c731c821f00e8223d42f7ff6; patches are cumulative reference material, not a current-main application or test result. Review and rebase on maintained source, preserve all existing tests and published compatibility, then establish causal controls and actual conformance before shipping. No patch was applied or executed during intake.

The following sanitized unified diffs identify proposed reference material:

  • 0001-t01-Reuse-one-activity-lease-helper-per-Worker-process.patch — 18,487 bytes, SHA-256 8a491c9e4c7cdbe370e60c829be8980b8d255dd6e3d7f4aed7f27ca4b4ea5e02.

Next action: the owning maintainer reviews the candidate against current source and the acceptance criteria above. Preserve durability, lease/fencing, idempotency, fairness and whole-completion/history budgets. New producer capabilities need versioned schemas and real producer/consumer compatibility proof; historical/frozen evidence is not rewritten.

Activity

  1. added
    kind:featureA public product capability or experience is requested
    priority:P0Immediate release or data-integrity risk
    repo:sdk-phpOwned by the PHP SDK repository
    on Nov 6, 2026
  2. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    Sanitized reference diff 0001-t01-Reuse-one-activity-lease-helper-per-Worker-process.patch, part 1/1.

    This is an unverified, cumulative draft from the external report. Mail author/date/commit-message metadata was removed; the unified-diff hunks were retained. No patch was applied or executed, and no build/test or compatibility result is implied. The known gaps and acceptance criteria in this issue remain mandatory.

    Complete sanitized patch: 18,487 bytes, SHA-256 8a491c9e4c7cdbe370e60c829be8980b8d255dd6e3d7f4aed7f27ca4b4ea5e02. This part: 18,487 bytes, SHA-256 8a491c9e4c7cdbe370e60c829be8980b8d255dd6e3d7f4aed7f27ca4b4ea5e02. Parts concatenate in order; a part boundary is between complete file diffs.

    diff --git a/src/Worker.php b/src/Worker.php
    index 3399394..971ca54 100644
    --- a/src/Worker.php
    +++ b/src/Worker.php
    @@ -1622,7 +1622,8 @@ final class Worker
                     }
                     $this->activityLeaseProcess = new ActivityLeaseProcess($this->client, $task, $this->workerId,
                         $this->taskQueue, $response['server_time'] ?? null, hrtime(true) / 1e9,
    -                    ['registered' => $this->registered, 'heartbeat_interval' => $this->heartbeatIntervalSeconds]);
    +                    ['registered' => $this->registered, 'heartbeat_interval' => $this->heartbeatIntervalSeconds,
    +                        'presence_due_in' => max(0.0, $this->heartbeatIntervalSeconds - $this->elapsedSinceHeartbeat())]);
                     $this->unbindActivityLease = $this->client->bindActivityLease($this->activityLeaseProcess);
     
                     $process = $this->activityLeaseProcess;
    diff --git a/src/Worker/ActivityLeaseMaintainer.php b/src/Worker/ActivityLeaseMaintainer.php
    index a947e77..b90fd38 100644
    --- a/src/Worker/ActivityLeaseMaintainer.php
    +++ b/src/Worker/ActivityLeaseMaintainer.php
    @@ -13,7 +13,13 @@ use Throwable;
     /** @internal Private metadata-only helper. Never dispatches an activity callback. */
     final class ActivityLeaseMaintainer
     {
    -    private string $buffer = '';
    +    /** Input not yet read as a line, kept across the activities one helper serves. */
    +    private static string $buffer = '';
    +    /** @var array{string, Client}|null The configuration a cached Client was built from. */
    +    private static ?array $cachedClient = null;
    +    /** The configuration whose Client has completed a renewal in this helper. */
    +    private static ?string $provenKey = null;
    +    private readonly string $clientKey;
         private ?string $lostReason = null;
         private float $nextRenewal = 0;
         private float $nextPresence = 0;
    @@ -29,34 +35,82 @@ final class ActivityLeaseMaintainer
                 || !is_numeric($start['captured_at'] ?? null) || !is_int($start['parent_pid'] ?? null)) {
                 throw new \UnexpectedValueException('Malformed private activity maintenance configuration.');
             }
    -        $this->client = self::client($configuration)->withBoundedWorkerRequests();
    +        // One Worker's helper serves its activities one after another, with
    +        // the same configuration each time, so it builds its Client once.
    +        $this->clientKey = $key = json_encode($configuration, JSON_THROW_ON_ERROR);
    +        if (self::$cachedClient === null || self::$cachedClient[0] !== $key) {
    +            self::$cachedClient = [$key, self::client($configuration)->withBoundedWorkerRequests()];
    +        }
    +        $this->client = self::$cachedClient[1];
             $this->authority = new ActivityLeaseAuthority($start['task'], $this->client->namespace,
                 $start['owner'], $start['queue'], $start['server_time'] ?? null, (float) $start['captured_at']);
    +        // Presence is due when the Worker's own heartbeat is, not at once.
    +        $due = $start['presence_due_in'] ?? 0;
    +        $this->nextPresence = hrtime(true) / 1e9 + (is_int($due) || is_float($due) ? max(0, min(10, $due)) : 0);
         }
     
    +    /** Serves one activity per `start`, each ended by `finish`, until the Worker goes away. */
         public static function run(): void
         {
    -        $first = fgets(STDIN, 65537);
    -        $start = [];
    -        try {
    -            if ($first === false || !str_ends_with($first, "\n")) {
    +        stream_set_blocking(STDIN, false);
    +        $parent = null;
    +        while (($start = self::awaitStart($parent)) !== null) {
    +            try {
    +                if (($start['operation'] ?? null) !== 'start') {
    +                    return;
    +                }
    +                $maintainer = new self($start);
    +                $parent = $start['parent_pid'];
    +                $maintainer->maintainInitialGrant();
    +                $maintainer->respond($start);
    +            } catch (Throwable) {
    +                // Bootstrap exceptions can contain application secrets. Only a
    +                // bounded protocol reason is ever written to the parent pipe.
    +                self::write(['id' => $start['id'] ?? 0,
    +                    'can_continue' => false, 'reason' => 'activity_maintenance_bootstrap_failed']);
    +
                     return;
                 }
    -            $start = json_decode($first, true, 32, JSON_THROW_ON_ERROR);
    -            if (!is_array($start) || ($start['operation'] ?? null) !== 'start') {
    +            if (!$maintainer->loop()) {
                     return;
                 }
    -            $maintainer = new self($start);
    -            stream_set_blocking(STDIN, false);
    -            $maintainer->maintainInitialGrant();
    -            $maintainer->respond($start);
    -            $maintainer->loop();
    +        }
    +    }
    +
    +    /**
    +     * The next command while no activity is being maintained, or null once the
    +     * Worker has gone away.
    +     *
    +     * @return array<string, mixed>|null
    +     */
    +    private static function awaitStart(?int $parent): ?array
    +    {
    +        while (!str_contains(self::$buffer, "\n")) {
    +            if ($parent !== null && function_exists('posix_getppid') && posix_getppid() !== $parent) {
    +                return null;
    +            }
    +            $read = [STDIN];
    +            $write = $except = null;
    +            if (@stream_select($read, $write, $except, 1) === false) {
    +                return null;
    +            }
    +            $part = stream_get_contents(STDIN, 65537 - strlen(self::$buffer));
    +            if ($part === false || strlen(self::$buffer) + strlen($part) > 65536) {
    +                return null;
    +            }
    +            if ($part === '' && feof(STDIN)) {
    +                return null;
    +            }
    +            self::$buffer .= $part;
    +        }
    +        [$line, self::$buffer] = explode("\n", self::$buffer, 2);
    +        try {
    +            $command = json_decode($line, true, 32, JSON_THROW_ON_ERROR);
             } catch (Throwable) {
    -            // Bootstrap exceptions can contain application secrets. Only a
    -            // bounded protocol reason is ever written to the parent pipe.
    -            self::write(['id' => is_array($start) ? ($start['id'] ?? 0) : 0,
    -                'can_continue' => false, 'reason' => 'activity_maintenance_bootstrap_failed']);
    +            return null;
             }
    +
    +        return is_array($command) ? $command : null;
         }
     
         /** @param array<string, mixed> $configuration */
    @@ -111,7 +165,8 @@ final class ActivityLeaseMaintainer
                 workerRequestTimeoutSeconds: $workerRequestTimeout === null ? null : (float) $workerRequestTimeout);
         }
     
    -    private function loop(): void
    +    /** Maintains this activity until `finish` (true) or until its Worker goes away (false). */
    +    private function loop(): bool
         {
             while (!feof(STDIN) && $this->parentPresent()) {
                 $started = hrtime(true) / 1e9;
    @@ -121,20 +176,35 @@ final class ActivityLeaseMaintainer
                 $commands = $this->readCommands();
                 $this->applyProgressCommands($commands);
                 $this->applyRenewal($reply, $started);
    -            foreach ($commands as $command) {
    +            foreach ($commands as $index => $command) {
    +                if (($command['operation'] ?? null) === 'finish') {
    +                    // The parent waits for this reply before its next start.
    +                    // Anything after it is outside the protocol.
    +                    self::write(['id' => $command['id'] ?? 0, 'finished' => true]);
    +
    +                    return $index === array_key_last($commands) && self::$buffer === '';
    +                }
                     if (!in_array($command['operation'] ?? null, ['check', 'progress'], true)) {
                         $this->lostReason = 'activity_maintenance_invalid_command';
                     }
                     $this->respond($command);
                 }
    -            usleep(10000);
    +            // Wake for the parent's next command at once, else within 10 ms.
    +            $read = [STDIN];
    +            $write = $except = null;
    +            @stream_select($read, $write, $except, 0, 10000);
             }
    +
    +        return false;
         }
     
         /** @return array<string, mixed>|null */
         private function renewWhenDue(float $started): ?array
         {
    -        if ($this->lostReason !== null || $started < $this->nextRenewal) {
    +        // Presence goes out with a renewal, and no later than it is due.
    +        $due = ($this->start['registered'] ?? false) === true
    +            ? min($this->nextRenewal, $this->nextPresence) : $this->nextRenewal;
    +        if ($this->lostReason !== null || $started < $due) {
                 return null;
             }
             try {
    @@ -177,8 +247,18 @@ final class ActivityLeaseMaintainer
     
         private function maintainInitialGrant(): void
         {
    +        // The first activity proves this helper's Client can renew before its
    +        // callback runs. After that, a grant the poll delivered moments ago
    +        // needs no renewal yet: the first comes on the usual schedule.
    +        if (self::$provenKey === $this->clientKey && hrtime(true) / 1e9 - (float) $this->start['captured_at'] < 1
    +            && $this->authority->remaining() >= 3) {
    +            $this->scheduleRenewal();
    +
    +            return;
    +        }
             $started = hrtime(true) / 1e9;
             $this->authority->acknowledge($this->requestRenewal(), $started);
    +        self::$provenKey = $this->clientKey;
             $this->scheduleRenewal();
         }
     
    @@ -229,14 +309,14 @@ final class ActivityLeaseMaintainer
         /** @return list<array<string, mixed>> */
         private function readCommands(): array
         {
    -        $part = stream_get_contents(STDIN, 65537 - strlen($this->buffer));
    -        if ($part === false || strlen($part) + strlen($this->buffer) > 65536) {
    +        $part = stream_get_contents(STDIN, 65537 - strlen(self::$buffer));
    +        if ($part === false || strlen($part) + strlen(self::$buffer) > 65536) {
                 throw new \UnexpectedValueException('Activity maintenance input exceeded its private bound.');
             }
    -        $this->buffer .= $part;
    +        self::$buffer .= $part;
             $commands = [];
    -        while (str_contains($this->buffer, "\n")) {
    -            [$line, $this->buffer] = explode("\n", $this->buffer, 2);
    +        while (str_contains(self::$buffer, "\n")) {
    +            [$line, self::$buffer] = explode("\n", self::$buffer, 2);
                 $command = json_decode($line, true, 32, JSON_THROW_ON_ERROR);
                 if (!is_array($command)) {
                     throw new \UnexpectedValueException('Invalid private activity command.');
    diff --git a/src/Worker/ActivityLeaseProcess.php b/src/Worker/ActivityLeaseProcess.php
    index 84ff265..8efdc48 100644
    --- a/src/Worker/ActivityLeaseProcess.php
    +++ b/src/Worker/ActivityLeaseProcess.php
    @@ -9,7 +9,10 @@ use DurableWorkflow\Client;
     use DurableWorkflow\Transport\RequestBudget;
     use Throwable;
     
    -/** @internal A fresh PHP process maintains leases while the original callback stays inline. */
    +/**
    + * @internal A separate PHP process maintains leases while the original callback stays inline.
    + * One helper serves a Worker process's activities one after another.
    + */
     final class ActivityLeaseProcess
     {
         public static function available(): bool
    @@ -17,8 +20,16 @@ final class ActivityLeaseProcess
             return PHP_SAPI === 'cli' && function_exists('proc_open');
         }
     
    +    /**
    +     * The helper that finished its last activity cleanly, kept for the next one.
    +     *
    +     * @var array{process: resource, pipes: array<int, resource>, pid: int, key: string}|null
    +     */
    +    private static ?array $idle = null;
    +
         /** @var resource|null */
         private $process = null;
    +    private string $key = '';
         /** @var array<int, resource> */
         private array $pipes = [];
         private string $buffer = '';
    @@ -32,7 +43,7 @@ final class ActivityLeaseProcess
         private readonly array $identity;
     
         /** @param array<string, mixed> $task
    -     * @param array{registered: bool, heartbeat_interval: int} $presence
    +     * @param array{registered: bool, heartbeat_interval: int, presence_due_in?: float} $presence
          */
         public function __construct(Client $client, array $task, string $owner, string $queue,
             mixed $serverTime, float $captured, array $presence)
    @@ -56,16 +67,19 @@ final class ActivityLeaseProcess
             if ($autoload === null) {
                 throw new ActivityLeaseMaintenanceException('Activity maintenance requires this SDK\'s Composer autoloader.');
             }
    -        $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'],
    -            2 => ['file', PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'w']];
    -        $this->process = proc_open([PHP_BINARY, '-d', 'auto_prepend_file=', '-d', 'auto_append_file=',
    -            __DIR__.'/activity-lease-maintainer.php', $autoload],
    -            $descriptors, $this->pipes, options: ['bypass_shell' => true]);
    -        if (!is_resource($this->process)) {
    -            throw new ActivityLeaseMaintenanceException('Could not start independent activity lease maintenance.');
    -        }
    -        foreach ($this->pipes as $pipe) {
    -            stream_set_blocking($pipe, false);
    +        $this->key = hash('sha256', json_encode([$configuration, $autoload], JSON_THROW_ON_ERROR));
    +        if (!$this->adoptIdleHelper()) {
    +            $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'],
    +                2 => ['file', PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'w']];
    +            $this->process = proc_open([PHP_BINARY, '-d', 'auto_prepend_file=', '-d', 'auto_append_file=',
    +                __DIR__.'/activity-lease-maintainer.php', $autoload],
    +                $descriptors, $this->pipes, options: ['bypass_shell' => true]);
    +            if (!is_resource($this->process)) {
    +                throw new ActivityLeaseMaintenanceException('Could not start independent activity lease maintenance.');
    +            }
    +            foreach ($this->pipes as $pipe) {
    +                stream_set_blocking($pipe, false);
    +            }
             }
             try {
                 $this->exchange('start', ['client' => $configuration, 'task' => array_intersect_key($task,
    @@ -133,6 +147,17 @@ final class ActivityLeaseProcess
         }
     
         public function stop(): void
    +    {
    +        if ($this->parentPid === getmypid() && $this->releaseHelper()) {
    +            $this->process = null;
    +            $this->pipes = [];
    +
    +            return;
    +        }
    +        $this->terminate();
    +    }
    +
    +    private function terminate(): void
         {
             foreach ($this->pipes as $pipe) {
                 if (is_resource($pipe)) {
    @@ -162,6 +187,51 @@ final class ActivityLeaseProcess
             $this->process = null;
         }
     
    +    /** Takes over the idle helper when it was started by this process with the same configuration. */
    +    private function adoptIdleHelper(): bool
    +    {
    +        $idle = self::$idle;
    +        if ($idle === null || $idle['pid'] !== getmypid()) {
    +            // A forked callback never converses with its parent's helper.
    +            return false;
    +        }
    +        self::$idle = null;
    +        $this->process = $idle['process'];
    +        $this->pipes = $idle['pipes'];
    +        if ($idle['key'] === $this->key && $this->running()) {
    +            return true;
    +        }
    +        $this->terminate();
    +
    +        return false;
    +    }
    +
    +    /**
    +     * Ends this activity's maintenance and keeps the helper for the next one.
    +     * Only a helper that confirms the end, with nothing else in flight, is kept.
    +     */
    +    private function releaseHelper(): bool
    +    {
    +        if ($this->lostReason !== null || self::$idle !== null || $this->buffer !== '' || !$this->running()) {
    +            return false;
    +        }
    +        try {
    +            $id = ++$this->sequence;
    +            $deadline = hrtime(true) / 1e9 + 1;
    +            $this->writeMessage(json_encode(['id' => $id, 'operation' => 'finish'], JSON_THROW_ON_ERROR)."\n", $deadline);
    +            $reply = $this->readReply($deadline);
    +        } catch (Throwable) {
    +            return false;
    +        }
    +        if (!is_array($reply) || ($reply['id'] ?? null) !== $id || ($reply['finished'] ?? null) !== true
    +            || $this->buffer !== '') {
    +            return false;
    +        }
    +        self::$idle = ['process' => $this->process, 'pipes' => $this->pipes, 'pid' => getmypid(), 'key' => $this->key];
    +
    +        return true;
    +    }
    +
         private function waitForStop(): void
         {
             $until = hrtime(true) / 1e9 + 0.25;
    diff --git a/tests/WorkerActivityLeaseMaintenanceTest.php b/tests/WorkerActivityLeaseMaintenanceTest.php
    index 86545d1..ed30ac7 100644
    --- a/tests/WorkerActivityLeaseMaintenanceTest.php
    +++ b/tests/WorkerActivityLeaseMaintenanceTest.php
    @@ -71,6 +71,8 @@ final class WorkerActivityLeaseMaintenanceTest extends TestCase
         public function testBlockingCallbackStaysInTheOriginalProcessAndOutlastsTheRuntimeLease(): void
         {
             $worker = $this->worker();
    +        // Worker presence falls due while the callback blocks.
    +        (new \ReflectionProperty($worker, 'heartbeatIntervalSeconds'))->setValue($worker, 2);
             $captured = 0;
             $parent = getmypid();
             $worker->registerActivity('slow', static function (ActivityContext $context) use (&$captured, $parent): int {
    @@ -100,6 +102,20 @@ final class WorkerActivityLeaseMaintenanceTest extends TestCase
             self::assertSame($renewals, $this->state()['renewals'], 'Joined maintenance must stop after execution.');
         }
     
    +    public function testOneHelperServesTheWorkersActivitiesInTurn(): void
    +    {
    +        $worker = $this->worker();
    +        $worker->registerActivity('slow', static fn (ActivityContext $context): int => 1);
    +        $idle = new \ReflectionProperty(\DurableWorkflow\Worker\ActivityLeaseProcess::class, 'idle');
    +        self::assertTrue($worker->tick(0));
    +        $helper = proc_get_status($idle->getValue()['process'])['pid'];
    +        file_put_contents($this->directory.'/state.json', '{}');
    +        self::assertTrue($worker->tick(0));
    +        self::assertCount(1, $this->state()['outcomes'] ?? []);
    +        self::assertSame($helper, proc_get_status($idle->getValue()['process'])['pid']);
    +        self::assertTrue(proc_get_status($idle->getValue()['process'])['running']);
    +    }
    +
         #[DataProvider('lateOutcomes')]
         public function testExpiredBusinessAuthorityFencesBothReturnAndFailure(bool $throw): void
         {
  3. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    General is implementing this independent P0 on maintained afa5a95e3116cd69c731c821f00e8223d42f7ff6. The draft keeps helper custody on each Worker instance, avoiding cross-Worker sharing through process-global state. Each claim gets fresh authority and can reuse the process only after a matching, drained finish acknowledgment. A dead or unconfirmed helper is replaced; forked callbacks cannot use or terminate the parent channel. Presence follows the existing Worker heartbeat deadline, and idle/input waits use readiness rather than a 10ms polling loop.

    The supplied diff remains reference material. Current-source implementation and regression qualification are in progress, including distinct claim identities, helper death, separate Workers, callback forks, shutdown, existing lease/business/heartbeat fencing, and original transport/TLS/auth tests. No performance or published-package result is claimed. No producer change, installation, release or publication is selected by this source work.

  4. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    Source326 is accepted on actual main fd81c46f79f88fd00d38c6b9fae5c0ea5ef2599f through PR333, with20 focused lifecycle controls and actual three-helper→one-helper same-consumer proof. PR41success/1skip; main40success/2skips; actual mainPHP8.4 has8,234tests/85,359assertions/56optional skips. Final receipt: #333 (comment). Preview is bound to actual main; live published source6a821568/hash43c0159f is preserved. Owned branches/worktrees/source containers and completed bulk downloads are cleaned.

    The issue remains open for published-package qualification and release follow-through with the existing supported Server tuple; no tag or operating selection is made by source acceptance. The first helper still renews to prove its Client, while later fresh grants with at least three seconds of authority run to normal renewal. Short/stale grants retain immediate confirmation. General owns this remaining delivery; the PHP repo agent remains operator-Off.

  5. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    The PHP repository agent has resumed ownership. I assessed a narrow P0 backport against exact published 3.9.4 source 11085f0c624674a4b70d3a6fb3e83eaff3869fcb, without bringing the combined 3.10 cohort along. The concrete candidate is a4c7a4a, with the complete stable-base delta.

    The 12-file delta changes only Worker helper/grant internals, their tests, the official worker fixture and additive corpus guards. All existing stable guards are preserved. composer.json, Client, Replayer, framework support, dependency constraints, capability declarations, protocol versions, docs and workflows are byte-identical to 3.9.4. The three helper/grant classes are byte-identical to accepted main. The only cherry-pick conflict was the corpus guard tail; unrelated 3.10 clock guards were excluded because their classes are absent on this base.

    Focused PHP 8.4.27 validation passes 65 tests / 349 assertions, covering helper reuse/death/forks/cleanup, TLS/auth, fencing, fresh versus missing/malformed/stale clocks, payload/history delay and renewal fallback. Full stable-source suite, static analysis and immutable-base consumer controls are in progress. This is a source assessment, not published-package or Server conformance.

    Next action / owners: sdk-php completes the assessment and the eventual exact installed helper/fallback acceptance. General is evaluating this independent patch cut; no version, maintenance integration target, tag, workflow dispatch or publication is selected. The existing combined-minor gates in #293 stay intact. The already published Server activity-lease contract is sufficient for source compatibility investigation; real published-package qualification remains required before delivery is declared.

  6. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    General selects a 3.9.x maintenance source branch rooted at the exact published 3.9.4 commit 11085f0c624674a4b70d3a6fb3e83eaff3869fcb for the narrow #326/#327 backport assessment. The annotated tag was independently peeled to that commit; no existing 3.9* maintenance branch was present at the decision read.

    SDK PHP owns creating that branch if still absent and opening the coherent backport against it. The proposed a4c7a4aaca7362568fc3c50b965d658a6021d793 cut leaves Composer, Client, Replayer, framework dependencies, support and wire contracts on the stable base. Its owner-reported 3,790-test source result is useful source evidence; the immutable-base negatives and static analysis still need to finish.

    The stable CI accepts pull requests generally, but its push runs and Sonar push/PR filters only name main. Include the smallest source changes needed to qualify the maintenance branch through the same normal CI/Sonar checks after integration. Preserve existing checks and the documentation/manual publisher behavior. This selects a maintenance source target, not a tag or a release, and does not alter the combined 3.10 gates in #293.

    The exact Ubuntu24/PHP8.3.36/Composer2.10.3 initial profile already passed central297. The remaining #315 caller adoption and terminal/cleanup qualification still apply. A published Server carrying the #327 workflow-poll clock is also required for its positive installed optimization proof; the currently published older Server must retain renewal fallback. General owns the subsequent concrete patch release/profile disposition after this source and caller evidence returns. Both P0 issues remain open through their actual delivery acceptance.

  7. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    The independent stable-base source assessment is complete, and General selected the maintenance target in 326/6694918746. Draft #335 now targets 3.9.x, created at exact published 3.9.4 commit 11085f0c. Candidate f250aaec adds only CI/Sonar branch routing to the previously linked 12-file backport. All source checks, documentation/manual publisher behavior and the combined 3.10 cohort remain intact.

    Completed PHP 8.4.27 evidence: 65 focused tests / 349 assertions, the full stable-source suite of 3,790 tests / 19,875 assertions / 56 optional skips, PHPStan and Composer validation. The unchanged-base helper consumer fails with three helper PIDs instead of one. The official fresh-grant worker fixture fails against unchanged 3.9.4 because of the extra initial renewal and passes against the candidate. The unchanged corpus policy passes with 61 to 62 replay fixtures and a verified immutable-base worker fail/candidate pass. All stable guards are retained. Public-boundary, whitespace and diff secret/private-data review pass.

    Local source containers are removed and their exact prefix is absent. Two initial source-harness faults are retained as environment failures: the first container UID could not traverse the read-only dependency mount, and the first temporary PHP bridge omitted stdin forwarding. Corrected unprivileged UID/stdin controls passed without changing SDK assertions or guards. These are not product failures or installed evidence.

    Next action / owner: sdk-php owns normal exact-head #335 checks/review, maintenance integration, actual-branch qualification and cleanup. Published-package/Server acceptance remains open. No tag, package version, installed tuple, workflow dispatch or publication has been selected. #327's positive optimization still requires a published Server with the accepted #1070 clock; the old-Server renewal fallback remains covered at source level.

  8. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    Correction to the scope of the observed green PR335 Sonar result after inspecting the scanner and provider metadata:

    • Final PR scanner log requests PR335 into 3.9.x, computes Git merge base 11085f0c624674a4b70d3a6fb3e83eaff3869fcb, records revision b389182db2b1878a78306a9935fb88a8fe7da6c5, and receives a PASSED response.
    • The provider's current api/project_pull_requests/list metadata for key335 binds the same commit and target 3.9.x, but its analysis base is main, not 3.9.x. The component metadata reports the matching 2026-11-06T20:39:40+0000 analysis. PR336 separately shows both base and target as main.
    • Therefore the green check/zero findings are preserved as an observed provider result with a main analysis baseline. I withdraw the earlier interpretation that it independently qualifies the maintenance change against 3.9.x. Matching the head/merge tree proves source identity, not the missing analysis-baseline authority.

    Actual merge 9f1487e9 still has exact reviewed tree 7438c819 and the expected ordered parents. Ordinary maintenance CI and Public Boundary passed; merged non-main Sonar remains NONPASS/403. The official Free-plan PR/main restriction and this actual metadata agree that maintenance Sonar acceptance is still missing.

    I immediately returned this precise base/target distinction to General, who owns the account/configuration disposition. No branch/project/provider reroute, main-label/account/plan/credential change, private-project change, fee, retry, release or gate waiver is selected. #326/#327 remain open through real qualification and published delivery.

  9. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    The release handoff is now concrete: draft #337 proposes SDK 3.9.5 at ba94a9c, based on accepted maintenance source 9f1487e9. It changes only the changelog, Composer product-train and quickstart release identities, following the existing #289 process. No production, dependency, support or protocol changes are added. Main already has the equivalent helper/grant code through #333/#334.

    General owns the account/configuration and actual release disposition. The #335 maintenance Sonar NONPASS/403 and PR provider base=main/target=3.9.x distinction remain release-readiness gaps; normal source CI does not clear them. This draft is not a tag or publication.

    Next action: finish normal candidate checks, obtain the applicable release disposition through the existing safeguards, then publish the exact immutable patch through the normal process. sdk-php owns verifying the GitHub release and exact Packagist source/dist/package files, linking the release here and closing the delivered issue. Published SDK is still 3.9.4, so this issue remains open.

  10. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    Release review found a concrete #326 timing regression in maintenance commit9f1487e9. A reused helper decides a poll grant is fresh from the response-observer capture time, without charging the poll's transport time. The existing local lease fixture confirms that a delayed response can arrive after its original grant expired and still dispatch the next activity callback. The new control fails on the unchanged candidate: two callbacks observed where only the first is authorized. This is a local synthetic fixture result, not a real Server or credential experiment.

    I own the correction before publication: preserve the original poll timing, use conservative grant bounds for the reuse fast path, and retain confirmation for stale or unknown deliveries. Fast valid grants must still reuse one helper; unchanged attempt/deadline, fork, shutdown and fallback controls remain. Draft #337 stays held until the fix and ordinary qualification are complete. The independent Sonar account/base restriction remains separately owned by General. No tag or release was created.

  11. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    The unchanged corpus policy passes against exact main base 9e7d89201fcdd2eddf11291bd74ba8c7e8727874 and candidate 878e0101285379a9978cc5316ecd35749dcf6b22: replay corpus 75→76, actual official-worker base fail / candidate pass, with all existing immutable fixtures retained. The selected-SDK bootstrap validates both parent and helper source identity.

    A direct unchanged-base run independently fails on php-activity-poll-transit-consumes-grant.expected.activity_callback_count, rather than an autoload/process/environment error. The candidate official fixture passes. Focused source tests pass 27 tests / 215 assertions and PHPStan is clean. The initial new fixture used decode on an envelope; that test-harness type error was corrected to decodeEnvelope before these passing runs and is not product evidence.

    The fix requires original poll timing for the shortcut, charges the full transit against all original bounds, and confirms unknown, slow or short grants. Existing fast reuse, fork, shutdown, attempt/deadline and old-Server controls remain in the normal suite. Current main PR CI is running; maintenance backport and draft release #337 remain held until source acceptance. No package has been published.

  12. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    The delayed-grant correction is now merged on main through #338 at c87eae3 (reviewed tree47ac7e26), with 41 successful PR checks / one intended skip and finite Security Source/custody PASS. Exact draft maintenance backport #339 at78f25360 passes 94 focused tests / 340 assertions, PHPStan and unchanged-policy 62→63 actual-worker base-fail/candidate-pass. Its normal PR checks are running. General has confirmed ownership of the legitimate maintenance quality route and qualified3.9.5 release execution. #339 and release #337 remain held; the observed PR339 Sonar report again uses base main / target3.9.x, so it is not maintenance quality acceptance. No release or installed result is claimed.

  13. rmcdaniel commented on Nov 6, 2026

    @rmcdaniel
    MemberAuthor

    Finite Security source/custody review now passes exact maintenance backport #339 head 78f25360049071bafe1dc8e521a75b48fff4ec29 / tree 7f2e3fa09d9b623c307415a9a29a56ed4fd0b601. Security independently verified the complete patch ID equals accepted main, the conservative poll-start path and actual delayed-delivery corpus, with all 35 reported checks terminal (34 success / one intended skip).

    The remaining next action is General's supported maintenance-quality disposition. Sonar still reports analysis base main despite target3.9.x, and the retained actual non-main scan remains NONPASS/403. #339 stays draft, then sdk-php refreshes release #337 after applicable acceptance; General owns final qualified3.9.5 execution. The current release draft is unchanged and no package is published. #327's supported published Server1070 and old-Server fallback acceptance remains separately General/Server-owned.

  14. rmcdaniel commented on Nov 7, 2026

    @rmcdaniel
    MemberAuthor

    The #339 transit correction is now normally merged to maintenance as063e5af6, with the exact reviewed tree and parents verified. The operator selected ordinary green CI plus Security Source review as maintenance quality; Sonar3.9.x is not required and remains a separately attributed tooling failure under nonblocking #342. I refreshed release #337 to exactdbeae40 on that corrected source, still only three release metadata files, and fresh normal checks are running. General retains single-owner actual publication; sdk-php retains package/install verification before delivery closure.

  15. rmcdaniel commented on Nov 7, 2026

    @rmcdaniel
    MemberAuthor

    Delivered in SDK3.9.5, exact57562b53/tree6a58907. Normal-registry installed proof binds Packagist/lock/installed source+dist, all205 production files and release manifests, actual Composer/worker identity, official-worker grant controls and18 focused tests/87 assertions. Installed helper reuse, helper-death replacement, fork isolation, shutdown and delayed-grant refusal pass. Both bounded containers are removed. Equivalent source is already accepted on main through #333/#338. This closes the delivered helper/process and lease-safety criteria; reported benchmark timings remain unverified and no real Server speedup is claimed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent:sdk-phpWork done by the sdk-php repo agentkind:featureA public product capability or experience is requestedpriority:P0Immediate release or data-integrity riskrepo:sdk-phpOwned by the PHP SDK repository

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions