Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/security-standards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
- cron: "0 0 * * 0"
push:
branches: [ "main", "master" ]
tags: [ "v*", "[0-9]*" ]
pull_request:
branches: [ "main", "master", "develop", "development" ]

Expand All @@ -14,6 +15,7 @@ concurrency:

jobs:
phpforge:
if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')
uses: infocyph/phpforge/.github/workflows/security-standards.yml@main
permissions:
security-events: write
Expand All @@ -29,7 +31,16 @@ jobs:
integration_services: '[]'
service_topologies: '{}'

release:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
uses: infocyph/phpforge/.github/workflows/release.yml@main
permissions:
contents: write
secrets:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}

http3-quic:
if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')
name: "HTTP/3 + QUIC - PHP ${{ matrix.php-version }}"
runs-on: ubuntu-latest
container: "php:${{ matrix.php-version }}-cli"
Expand Down
7 changes: 6 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,14 +250,19 @@ Graceful sequence:

```text
stop accepting
→ application drain
→ protocol drain / GOAWAY
→ finish admission of previously accepted requests
→ application drain
→ finish admitted work
→ timeout
→ force remaining work
→ close resources
```

Native HTTP/1.1 connections accepted before stopping may finish their first exchange, including incomplete headers. Protocol draining prevents another keep-alive exchange. Application draining begins once these first requests have entered the application or their connections have closed; header/body deadlines and the recycling grace period remain enforced. Idle connections cannot extend recycling beyond that grace period.

HTTP/2 includes an accepted but incomplete HEADERS/CONTINUATION block in the GOAWAY boundary and lets it finish; higher stream IDs remain refused. HTTP/3 likewise lets previously accepted request streams finish decoding their headers before application draining begins. Both protocols retain their existing resource limits and drain deadlines. Native HTTP/3 continues advancing application tasks while draining.

Portable mode drains all attachments on its shared `SelectLoop` until complete or deadline expiry.

## 11. Application lifecycle hooks
Expand Down
10 changes: 8 additions & 2 deletions src/Http/Http1/Http1Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ function (): void {
}

/**
* Stop accepting further keep-alive work and drain the active exchange.
* Stop further keep-alive work and finish the accepted initial exchange.
*/
public function drain(): void
{
Expand All @@ -158,11 +158,17 @@ public function drain(): void
$this->responseCloseAfter = true;
$this->writer?->forceCloseAfterResponse();

if ($this->body === null && $this->writer === null) {
if ($this->writer === null && $this->requestCount > 0) {
$this->connection->closeGracefully();
}
}

/** @internal Reports an accepted connection awaiting its first request. */
public function hasPendingRequestAdmission(): bool
{
return $this->requestCount === 0;
}

/**
* Return the number of requests processed on this connection.
*/
Expand Down
10 changes: 8 additions & 2 deletions src/Http/Http2/Http2Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ public function drain(): void

$this->draining = true;
$this->requests->setDraining(true);
$this->output->sendControl(FrameWriter::goAway($this->requests->lastClientStreamId(), ErrorCode::NO_ERROR));
$this->output->sendControl(FrameWriter::goAway($this->requests->lastAcceptedStreamId(), ErrorCode::NO_ERROR));
$this->finishDrainIfReady();
if ($this->closed) {
return;
Expand All @@ -150,6 +150,12 @@ public function drain(): void
});
}

/** @internal Reports an accepted header block awaiting request dispatch. */
public function hasPendingRequestAdmission(): bool
{
return $this->requests->hasOpenHeaderBlock();
}

/**
* Return the greatest client-initiated stream ID observed.
*/
Expand Down Expand Up @@ -269,7 +275,7 @@ private function failConnection(ErrorCode $code, string $message): void

private function finishDrainIfReady(): void
{
if (!$this->draining || $this->closed || $this->requests->count() !== 0 || !$this->output->wireIdle()) {
if (!$this->draining || $this->closed || $this->requests->count() !== 0 || $this->requests->hasOpenHeaderBlock() || !$this->output->wireIdle()) {
return;
}

Expand Down
12 changes: 9 additions & 3 deletions src/Http/Http2/Internal/RequestStreamProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ final class RequestStreamProcessor

private readonly RequestHeaderValidator $validator;

private bool $draining = false;
private ?int $drainBoundary = null;

private ?int $headerBlockTimer = null;

Expand Down Expand Up @@ -203,6 +203,12 @@ public function hasOpenHeaderBlock(): bool
return $this->pendingHeaders !== null;
}

/** Return the boundary including an accepted unfinished header block. */
public function lastAcceptedStreamId(): int
{
return max($this->lastClientStreamId, $this->pendingHeaders->streamId ?? 0);
}

/**
* Return the greatest client-initiated stream ID observed.
*/
Expand All @@ -226,7 +232,7 @@ public function reset(Http2Stream $stream): void
*/
public function setDraining(bool $draining): void
{
$this->draining = $draining;
$this->drainBoundary = $draining ? $this->lastAcceptedStreamId() : null;
}

/**
Expand Down Expand Up @@ -295,7 +301,7 @@ private function acceptData(Http2Stream $stream, Frame $frame): void

private function admit(PendingHeaderBlock $pending, ?int $contentLength): bool
{
if ($this->draining) {
if ($this->drainBoundary !== null && $pending->streamId > $this->drainBoundary) {
$this->output->sendControl(FrameWriter::rstStream($pending->streamId, ErrorCode::REFUSED_STREAM));

return false;
Expand Down
6 changes: 6 additions & 0 deletions src/Http/Http3/Http3Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ public function finishRequestStream(int $streamId): void
$this->dispatchReadyRequests();
}

/** @internal Reports whether an accepted stream still needs application admission. */
public function hasPendingRequestAdmission(int $streamId): bool
{
return !isset($this->dispatchedRequestStreams[$streamId]);
}

/**
* Feed bytes from a peer unidirectional stream into connection state.
*/
Expand Down
6 changes: 6 additions & 0 deletions src/Http/Http3/Quic/PhpQuicHttp3Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,12 @@ public function handleReady(array $ready, PhpQuicEventMasks $events): void
}
}

/** @internal Reports accepted streams awaiting headers or QPACK decoding. */
public function hasPendingRequestAdmission(): bool
{
return array_any(array_keys($this->requestStreams), $this->session->hasPendingRequestAdmission(...));
}

/**
* Return a tracked peer stream by QUIC stream ID.
*/
Expand Down
6 changes: 6 additions & 0 deletions src/Http/Http3/Quic/PhpQuicHttp3Worker.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ public function forceClose(): void
}
}

/** @internal Reports accepted request streams awaiting application admission. */
public function hasPendingRequestAdmission(): bool
{
return array_any($this->connections, fn($connection) => $connection->hasPendingRequestAdmission());
}

/**
* Stop accepting new connections and begin graceful draining.
*/
Expand Down
6 changes: 6 additions & 0 deletions src/Http/NativeHttpConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,10 @@ public function drain(): void
{
$this->protocol->drain();
}

/** @internal Reports accepted input whose request has not reached the application. */
public function hasPendingRequestAdmission(): bool
{
return $this->protocol->hasPendingRequestAdmission();
}
}
15 changes: 14 additions & 1 deletion src/Runtime/Internal/NativeHttp3Attachment.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ final class NativeHttp3Attachment
{
private readonly WorkerStopState $state;

private bool $applicationDraining = false;

private ?int $drainTimer = null;

private ?int $pollTimer = null;
Expand Down Expand Up @@ -76,8 +78,8 @@ private function beginDrain(): void
}

$this->state->stop();
$this->application->drain($this->context->shutdownReason());
$this->worker->stopAccepting();
$this->drainApplication();
$this->drainTimer = $this->loop->delay(
$this->context->recyclePolicy->gracefulTimeoutSeconds,
$this->expireDrain(...),
Expand Down Expand Up @@ -121,6 +123,16 @@ private function close(): void
$this->shutdown();
}

private function drainApplication(): void
{
if (!$this->state->isStopping() || $this->applicationDraining || $this->worker->hasPendingRequestAdmission()) {
return;
}

$this->applicationDraining = true;
$this->application->drain($this->context->shutdownReason());
}

private function drained(): bool
{
return $this->state->isStopping() && $this->worker->drainComplete();
Expand All @@ -136,6 +148,7 @@ private function expireDrain(): void

private function finishDrain(): void
{
$this->drainApplication();
if (!$this->drained()) {
return;
}
Expand Down
19 changes: 18 additions & 1 deletion src/Runtime/Internal/NativeHttp3Worker.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,24 +145,27 @@ public static function run(
}

$context->consumeStopWake();
$application->drain($context->shutdownReason());
$worker->stopAccepting();
$applicationDraining = false;
$deadline = $context->recycling()
? MonotonicTime::deadlineAfterSeconds(
MonotonicTime::nowNanoseconds(),
$context->recyclePolicy->gracefulTimeoutSeconds,
)
: null;
while (!$worker->drainComplete()) {
self::drainApplication($application, $context, $worker, $applicationDraining);
if ($deadline !== null && MonotonicTime::nowNanoseconds() >= $deadline) {
$worker->forceClose();

break;
}
$worker->tick($options->pollTimeoutSeconds);
$taskLoop->tick();
self::observeTransport($runtimeContext, $worker);
$sampler->sample();
}
self::drainApplication($application, $context, $worker, $applicationDraining);
self::observeTransport($runtimeContext, $worker);
$sampler->sample(true);
} catch (Throwable $error) {
Expand Down Expand Up @@ -193,6 +196,20 @@ public static function run(
ApplicationShutdown::resolve($failure, $shutdownFailures);
}

private static function drainApplication(
\Infocyph\Runwire\Runtime\RuntimeApplicationInterface $application,
WorkerContext $context,
PhpQuicHttp3Worker $worker,
bool &$applicationDraining,
): void {
if ($applicationDraining || $worker->hasPendingRequestAdmission()) {
return;
}

$applicationDraining = true;
$application->drain($context->shutdownReason());
}

/** @return array{0: string, 1: int} */
private static function endpoint(string $address): array
{
Expand Down
Loading
Loading