diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml
index 5253626..bb8f0c4 100644
--- a/.github/workflows/security-standards.yml
+++ b/.github/workflows/security-standards.yml
@@ -11,38 +11,47 @@ on:
jobs:
phpforge:
uses: infocyph/phpforge/.github/workflows/security-standards.yml@main
+ with:
+ integration_services: '[]'
+ service_topologies: '{}'
+ php_extensions: "curl, fileinfo, openssl"
permissions:
security-events: write
actions: read
contents: read
- with:
- php_versions: '["8.4","8.5"]'
- dependency_versions: '["prefer-lowest","prefer-stable"]'
- php_extensions: "curl, fileinfo, openssl"
- composer_flags: ""
- phpstan_memory_limit: "1G"
- psalm_threads: "1"
- run_analysis: true
- run_svg_report: true
- run_clean_install: true
- benchmark_composer_script: ""
- benchmark_result_file: ""
- benchmark_baseline_file: ""
- benchmark_max_regression_percent: 2
- benchmark_stable_environment: false
- fail_on_skipped_tests: true
- enable_redis_service: false
- enable_valkey_service: false
- enable_memcached_service: false
- enable_postgres_service: false
- enable_mysql_service: false
- enable_scylladb_service: false
- enable_elasticsearch_service: false
- enable_mongodb_service: false
- service_db_name: "phpforge"
- service_db_user: "phpforge"
- service_db_password: "phpforge"
- artifact_retention_days: 61
+
+ optional-capability-coldness:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Setup minimal PHP runtime
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.5"
+ extensions: "curl, fileinfo, openssl, :grpc, :imap, :posix"
+ coverage: none
+
+ - name: Verify unloadable optional extensions are absent
+ run: |
+ php -r '
+ foreach (["grpc", "imap", "posix"] as $extension) {
+ if (extension_loaded($extension)) {
+ fwrite(STDERR, "Optional extension unexpectedly loaded: {$extension}\n");
+ exit(1);
+ }
+ }
+ '
+
+ - name: Install dependencies
+ run: composer install --no-interaction --prefer-dist --no-progress
+
+ - name: Run optional capability coldness gate
+ env:
+ TALKINGBYTES_OPTIONAL_COLDNESS: "1"
+ run: vendor/bin/pest --configuration=vendor/infocyph/phpforge/resources/pest.xml --bootstrap=vendor/autoload.php tests/OptionalCapabilityColdnessTest.php
mailpit-integration:
runs-on: ubuntu-latest
@@ -55,21 +64,43 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.5'
+ php-version: "8.5"
extensions: curl, fileinfo, openssl
+ coverage: none
- name: Install dependencies
- run: composer install --no-interaction --prefer-dist
+ run: composer install --no-interaction --prefer-dist --no-progress
- name: Run Mailpit integration test
env:
- RUN_MAILPIT_INTEGRATION: '1'
+ RUN_MAILPIT_INTEGRATION: "1"
SMTP_HOST: 127.0.0.1
SMTP_PORT: 1025
MAILPIT_API_BASE: http://127.0.0.1:8025
run: vendor/bin/pest --configuration=vendor/infocyph/phpforge/resources/pest.xml --bootstrap=vendor/autoload.php --fail-on-skipped tests/MailpitIntegrationTest.php
+
+ docs-warning-free:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Setup Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.13"
+ cache: pip
+ cache-dependency-path: docs/requirements.txt
+
+ - name: Install documentation dependencies
+ run: python -m pip install --disable-pip-version-check -r docs/requirements.txt
+
+ - name: Build documentation with warnings as errors
+ run: sphinx-build -W --keep-going -b html docs build/docs
+
diff --git a/.gitignore b/.gitignore
index 6991d72..cf24d2a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,4 @@ test.php
var
vendor
d2utmp*
+/graphify-out
diff --git a/benchmarks/EmailBench.php b/benchmarks/EmailBench.php
index a53dfa8..d744703 100644
--- a/benchmarks/EmailBench.php
+++ b/benchmarks/EmailBench.php
@@ -4,7 +4,9 @@
namespace Infocyph\TalkingBytes\Benchmarks;
+use Infocyph\TalkingBytes\Email\Emailer;
use Infocyph\TalkingBytes\Email\EmailMessage;
+use Infocyph\TalkingBytes\Email\Mailbox\FakeMailboxTransport;
use Infocyph\TalkingBytes\Email\Parser\RawEmailParser;
use Infocyph\TalkingBytes\Email\System\RawEmailBuilder;
use PhpBench\Attributes\BeforeMethods;
@@ -16,8 +18,14 @@ final class EmailBench
{
private RawEmailBuilder $builder;
+ private Emailer $fakeEmailer;
+
+ private FakeMailboxTransport $mailbox;
+
private EmailMessage $message;
+ private Emailer $nullEmailer;
+
private RawEmailParser $parser;
private string $rawMultipartEmail;
@@ -26,6 +34,8 @@ public function setUp(): void
{
$this->builder = new RawEmailBuilder();
$this->parser = new RawEmailParser();
+ $this->nullEmailer = Emailer::usingNull();
+ $this->fakeEmailer = Emailer::fake();
$this->message = EmailMessage::new()
->from('sender@example.com', 'Sender Name')
->to('alice@example.com', 'bob@example.com')
@@ -37,6 +47,7 @@ public function setUp(): void
->attachData(str_repeat('PDF-DATA-', 64), 'report.pdf', 'application/pdf')
->attachInlineData('', 'logo.svg', 'logo-inline', 'image/svg+xml');
$this->rawMultipartEmail = $this->createRawMultipartEmail();
+ $this->mailbox = (new FakeMailboxTransport())->withMessage('INBOX', 1, $this->rawMultipartEmail);
}
#[Iterations(5)]
@@ -58,6 +69,29 @@ static function (string $chunk): void {
);
}
+ #[Iterations(5)]
+ #[Revs(100)]
+ public function benchFakeMailboxAdapter(): void
+ {
+ $this->mailbox->rawMessage('INBOX', 1);
+ $this->mailbox->rawHeaders('INBOX', 1);
+ $this->mailbox->status('INBOX');
+ }
+
+ #[Iterations(5)]
+ #[Revs(100)]
+ public function benchFakeSend(): void
+ {
+ $this->fakeEmailer->send($this->message);
+ }
+
+ #[Iterations(5)]
+ #[Revs(500)]
+ public function benchNullSend(): void
+ {
+ $this->nullEmailer->send($this->message);
+ }
+
#[Iterations(5)]
#[Revs(50)]
public function benchParseMultipartEmail(): void
diff --git a/benchmarks/GrpcBench.php b/benchmarks/GrpcBench.php
index 6e1baf4..a8494ad 100644
--- a/benchmarks/GrpcBench.php
+++ b/benchmarks/GrpcBench.php
@@ -4,13 +4,120 @@
namespace Infocyph\TalkingBytes\Benchmarks;
+use Infocyph\TalkingBytes\Grpc\GrpcClient;
+use Infocyph\TalkingBytes\Grpc\GrpcInboundDispatcher;
use Infocyph\TalkingBytes\Grpc\GrpcMetadata;
+use Infocyph\TalkingBytes\Grpc\GrpcStatus;
+use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundRequest;
+use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundResponse;
+use Infocyph\TalkingBytes\Grpc\Retry\GrpcRetryPolicy;
use Infocyph\TalkingBytes\Grpc\Sender\GrpcRequest;
+use Infocyph\TalkingBytes\Grpc\Sender\GrpcResponse;
+use Infocyph\TalkingBytes\Grpc\Testing\FakeGrpcInboundSource;
+use PhpBench\Attributes\BeforeMethods;
use PhpBench\Attributes\Iterations;
use PhpBench\Attributes\Revs;
+#[BeforeMethods('setUp')]
final class GrpcBench
{
+ private GrpcClient $client;
+
+ private GrpcInboundDispatcher $dispatcher;
+
+ private GrpcClient $generatedClient;
+
+ private GrpcInboundRequest $inboundRequest;
+
+ private GrpcRequest $request;
+
+ private GrpcClient $retryClient;
+
+ public function setUp(): void
+ {
+ $caller = static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse(
+ GrpcStatus::Ok,
+ $request->message,
+ );
+
+ $this->client = GrpcClient::using($caller);
+ $this->retryClient = GrpcClient::using($caller)
+ ->withGrpcRetry(GrpcRetryPolicy::standard(attempts: 2, baseDelayMs: 0));
+
+ $stub = new class {
+ public function GetOrder(mixed $message, array $metadata = [], array $options = []): object
+ {
+ unset($metadata, $options);
+
+ $call = new class {
+ public mixed $message = null;
+
+ public function wait(): array
+ {
+ return [$this->message, (object) ['code' => GrpcStatus::Ok->value]];
+ }
+
+ public function getMetadata(): array
+ {
+ return [];
+ }
+
+ public function getTrailingMetadata(): array
+ {
+ return [];
+ }
+ };
+ $call->message = $message;
+
+ return $call;
+ }
+ };
+
+ $this->generatedClient = GrpcClient::usingGeneratedStub(
+ $stub,
+ ['/orders.v1.OrderService/GetOrder' => 'GetOrder'],
+ );
+
+ $this->dispatcher = new GrpcInboundDispatcher([
+ '/orders.v1.OrderService/GetOrder' => static function (GrpcInboundRequest $request): GrpcInboundResponse {
+ return GrpcInboundResponse::ok($request->message);
+ },
+ ]);
+ $this->request = new GrpcRequest(
+ '/orders.v1.OrderService/GetOrder',
+ ['id' => 'order-1'],
+ new GrpcMetadata(['x-request-id' => ['bench-1']]),
+ 1.5,
+ );
+ $this->inboundRequest = new GrpcInboundRequest(
+ '/orders.v1.OrderService/GetOrder',
+ ['id' => 'order-1'],
+ );
+ }
+
+ #[Iterations(5)]
+ #[Revs(500)]
+ public function benchGeneratedStubInvocation(): void
+ {
+ $this->generatedClient->send($this->request);
+ }
+
+ #[Iterations(5)]
+ #[Revs(1000)]
+ public function benchHostAcceptedExchangeBridge(): void
+ {
+ $source = new FakeGrpcInboundSource();
+ $source->enqueue($this->inboundRequest);
+ $this->dispatcher->serveOne($source);
+ }
+
+ #[Iterations(5)]
+ #[Revs(1000)]
+ public function benchInboundDispatch(): void
+ {
+ $this->dispatcher->handle($this->inboundRequest);
+ }
+
#[Iterations(5)]
#[Revs(1000)]
public function benchRequestAndMetadata(): void
@@ -22,4 +129,18 @@ public function benchRequestAndMetadata(): void
1.5,
);
}
+
+ #[Iterations(5)]
+ #[Revs(1000)]
+ public function benchRetryMiddlewareSuccessPath(): void
+ {
+ $this->retryClient->send($this->request);
+ }
+
+ #[Iterations(5)]
+ #[Revs(1000)]
+ public function benchUnaryDispatch(): void
+ {
+ $this->client->send($this->request);
+ }
}
diff --git a/benchmarks/HttpBench.php b/benchmarks/HttpBench.php
index 9edb0b5..db46dbf 100644
--- a/benchmarks/HttpBench.php
+++ b/benchmarks/HttpBench.php
@@ -8,10 +8,13 @@
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
use Infocyph\TalkingBytes\Http\Contract\HttpMiddleware;
use Infocyph\TalkingBytes\Http\Contract\HttpTransport;
+use Infocyph\TalkingBytes\Http\Cookie\CookieJar;
use Infocyph\TalkingBytes\Http\HttpClient;
+use Infocyph\TalkingBytes\Http\HttpClientFactory;
use Infocyph\TalkingBytes\Http\HttpRequest;
use Infocyph\TalkingBytes\Http\Support\HeaderBag;
use Infocyph\TalkingBytes\Http\Support\QueryParams;
+use Infocyph\TalkingBytes\Http\Testing\FakeHttpTransport;
use PhpBench\Attributes\BeforeMethods;
use PhpBench\Attributes\Iterations;
use PhpBench\Attributes\Revs;
@@ -21,8 +24,15 @@ final class HttpBench
{
private HttpClient $client;
+ private HttpClient $cookieClient;
+
+ private HttpClient $fakeClient;
+
private HttpRequest $request;
+ /** @var array */
+ private array $resolvedConfig;
+
public function setUp(): void
{
$middleware = new class implements HttpMiddleware {
@@ -41,6 +51,19 @@ public function send(HttpRequest $request): CommunicationResult
->withMiddleware($middleware)
->withMiddleware($middleware)
->withMiddleware($middleware);
+ $this->fakeClient = HttpClient::using(new FakeHttpTransport());
+ $this->cookieClient = HttpClient::using(new FakeHttpTransport())
+ ->withCookieJar(new CookieJar());
+ $this->resolvedConfig = [
+ 'timeoutSeconds' => 5,
+ 'defaultHeaders' => ['Accept' => 'application/json'],
+ 'auth' => ['driver' => 'bearer', 'token' => 'bench-token'],
+ 'cookies' => ['enabled' => true],
+ 'retry' => ['enabled' => true, 'attempts' => 2, 'base_delay_ms' => 0],
+ 'rate_limit' => ['enabled' => true, 'max_requests' => 100000, 'per_seconds' => 1],
+ 'circuit_breaker' => ['enabled' => true, 'failure_threshold' => 5, 'cool_down_seconds' => 30],
+ 'idempotency' => ['enabled' => true, 'header' => 'Idempotency-Key'],
+ ];
$this->request = HttpRequest::post('https://api.example.com/v1/orders?existing=1#frag')
->header('X-App', 'TalkingBytes')
->header('X-Trace', 'bench-123')
@@ -78,6 +101,20 @@ public function benchBuildUrl(): void
$this->request->buildUrl();
}
+ #[Iterations(5)]
+ #[Revs(500)]
+ public function benchCookieEnabledFakeSend(): void
+ {
+ $this->cookieClient->send($this->request);
+ }
+
+ #[Iterations(5)]
+ #[Revs(1000)]
+ public function benchFakeTransportSend(): void
+ {
+ $this->fakeClient->send($this->request);
+ }
+
#[Iterations(5)]
#[Revs(1000)]
public function benchHeaderBag(): void
@@ -85,6 +122,13 @@ public function benchHeaderBag(): void
new HeaderBag(['Accept' => 'application/json', 'X-Trace' => ['one', 'two']]);
}
+ #[Iterations(5)]
+ #[Revs(1000)]
+ public function benchImmutableClientConstruction(): void
+ {
+ HttpClient::using(new FakeHttpTransport());
+ }
+
#[Iterations(5)]
#[Revs(1000)]
public function benchMiddlewarePipeline(): void
@@ -98,4 +142,18 @@ public function benchOrderedQueryParams(): void
{
new QueryParams(['a' => [1, 2], 'active' => true]);
}
+
+ #[Iterations(5)]
+ #[Revs(1000)]
+ public function benchPrepareRequest(): void
+ {
+ $this->request->prepareForTransport();
+ }
+
+ #[Iterations(5)]
+ #[Revs(250)]
+ public function benchResolvedFactoryConstruction(): void
+ {
+ (new HttpClientFactory())->fromArray($this->resolvedConfig, new FakeHttpTransport());
+ }
}
diff --git a/benchmarks/WebhookBench.php b/benchmarks/WebhookBench.php
index b020224..09dc581 100644
--- a/benchmarks/WebhookBench.php
+++ b/benchmarks/WebhookBench.php
@@ -4,7 +4,6 @@
namespace Infocyph\TalkingBytes\Benchmarks;
-use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus;
use Infocyph\TalkingBytes\Webhook\Model\WebhookSignature;
use Infocyph\TalkingBytes\Webhook\Replay\InMemoryWebhookReplayStore;
use Infocyph\TalkingBytes\Webhook\Signing\WebhookSignatureParser;
@@ -20,8 +19,12 @@ final class WebhookBench
private string $payload;
+ private int $replayCounter = 0;
+
private InMemoryWebhookReplayStore $replayStore;
+ private WebhookSignature $signature;
+
private string $signatureHeader;
private int $timestamp;
@@ -30,8 +33,6 @@ final class WebhookBench
public function setUp(): void
{
- CommunicationEventBus::listen(null);
-
$this->payload = json_encode([
'id' => 1001,
'event' => 'invoice.paid',
@@ -45,30 +46,56 @@ public function setUp(): void
],
], JSON_THROW_ON_ERROR);
$this->timestamp = 1_720_000_000;
- $this->signatureHeader = (new WebhookSignature('secret'))->buildHeader($this->payload, $this->timestamp);
+ $this->signature = new WebhookSignature('secret');
+ $this->signatureHeader = $this->signature->buildHeader($this->payload, $this->timestamp, 'invoice.paid', 'delivery-bench');
$this->verifier = new WebhookVerifier(['current-secret', 'secret'], 300);
$this->parser = new WebhookSignatureParser();
$this->replayStore = new InMemoryWebhookReplayStore(10_000);
+ $this->replayStore->claim('bench', 'duplicate', 60);
+ }
+
+ #[Iterations(5)]
+ #[Revs(1000)]
+ public function benchDuplicateRejection(): void
+ {
+ $this->replayStore->claim('bench', 'duplicate', 60);
}
#[Iterations(5)]
#[Revs(1000)]
public function benchParseSignature(): void
{
- $this->parser->parse($this->signatureHeader);
+ $this->parser->parse($this->signatureHeader, version: 'v2');
}
#[Iterations(5)]
#[Revs(1000)]
public function benchReplayClaim(): void
{
- $this->replayStore->claim('bench', bin2hex(random_bytes(8)), 60);
+ $this->replayCounter++;
+ $this->replayStore->claim('bench', 'delivery-' . $this->replayCounter, 60);
+ }
+
+ #[Iterations(5)]
+ #[Revs(1000)]
+ public function benchSignWebhook(): void
+ {
+ $this->signature->buildHeader($this->payload, $this->timestamp, 'invoice.paid', 'delivery-bench');
+ }
+
+ #[Iterations(5)]
+ #[Revs(1000)]
+ public function benchVerificationAndReplayClaim(): void
+ {
+ $this->verifier->verifyResult($this->payload, $this->signatureHeader, now: $this->timestamp, event: 'invoice.paid', deliveryId: 'delivery-bench');
+ $this->replayCounter++;
+ $this->replayStore->claim('verify', 'delivery-' . $this->replayCounter, 60);
}
#[Iterations(5)]
#[Revs(1000)]
public function benchVerifyWebhook(): void
{
- $this->verifier->verify($this->payload, $this->signatureHeader, $this->timestamp);
+ $this->verifier->verifyResult($this->payload, $this->signatureHeader, now: $this->timestamp, event: 'invoice.paid', deliveryId: 'delivery-bench');
}
}
diff --git a/captainhook.json b/captainhook.json
deleted file mode 100644
index 782a292..0000000
--- a/captainhook.json
+++ /dev/null
@@ -1,55 +0,0 @@
-{
- "commit-msg": {
- "enabled": false,
- "actions": []
- },
- "pre-push": {
- "enabled": false,
- "actions": []
- },
- "pre-commit": {
- "enabled": true,
- "actions": [
- {
- "action": "composer validate --strict",
- "options": []
- },
- {
- "action": "composer normalize --dry-run",
- "options": []
- },
- {
- "action": "composer ic:release:audit",
- "options": []
- },
- {
- "action": "composer ic:ci",
- "options": []
- }
- ]
- },
- "prepare-commit-msg": {
- "enabled": false,
- "actions": []
- },
- "post-commit": {
- "enabled": false,
- "actions": []
- },
- "post-merge": {
- "enabled": false,
- "actions": []
- },
- "post-checkout": {
- "enabled": false,
- "actions": []
- },
- "post-rewrite": {
- "enabled": false,
- "actions": []
- },
- "post-change": {
- "enabled": false,
- "actions": []
- }
-}
diff --git a/composer.json b/composer.json
index 0e8c5bd..3ed5b88 100644
--- a/composer.json
+++ b/composer.json
@@ -38,6 +38,7 @@
"ext-iconv": "Optional fallback for inbound charset conversion when mbstring is unavailable.",
"ext-imap": "Optional for imap_* address parsing and UTF7-IMAP fallback helpers.",
"ext-mbstring": "Recommended for robust charset conversion and encoded header handling.",
+ "ext-posix": "Optional best-effort Unix sendmail process-group termination for descendant cleanup.",
"ext-sodium": "Required for Ed25519-SHA256 DKIM signing and verification.",
"grpc/grpc": "Required for generated PHP gRPC clients."
},
diff --git a/docs/architecture.rst b/docs/architecture.rst
index edeb5e2..37d1a94 100644
--- a/docs/architecture.rst
+++ b/docs/architecture.rst
@@ -30,6 +30,53 @@ Examples:
Sensitive values are redacted before dispatch.
+Runtime ownership and lifetime
+------------------------------
+
+TalkingBytes objects are safe to reuse only according to the state they own.
+
+- immutable request/configuration objects may be reused.
+- an ``HttpClient`` without mutable collaborators is an immutable reusable
+ graph.
+- ``CookieJar`` owns mutable session state and should be scoped to the
+ intended request/session lifetime.
+- ``CircuitBreaker`` and ``RateLimiter`` own resilience state; sharing
+ them is an explicit host policy decision.
+- mailbox/socket transports own connection/session state and should remain
+ execution- or worker-owned rather than globally shared.
+- generated/native gRPC invokers inherit the lifetime of their channel/stub and
+ should be scoped deliberately by the host.
+- fakes and spies own mutable test history and should be test-scoped.
+
+No global registry is introduced for cookies, resilience state, mailbox
+connections, native clients, cancellation, or protocol events.
+
+Cancellation and host control
+-----------------------------
+
+Long-running protocol work accepts the small ``CancellationSignal`` boundary
+where interruption is useful. TalkingBytes checks that signal around retries,
+bounded waits, stream progress, concurrent HTTP scheduling, mailbox watches,
+sendmail process supervision, and accepted inbound gRPC exchanges.
+
+The host remains responsible for translating its own stop token, heartbeat,
+release generation, or worker lifecycle into that signal. TalkingBytes does not
+own worker supervision or process-global signal handling.
+
+Optional capability coldness
+----------------------------
+
+Optional protocol capabilities remain cold until selected. HTTP, webhook, and
+basic email graphs must not initialize gRPC, IMAP, POSIX, PCNTL, or Sodium
+capabilities. RSA DKIM is OpenSSL-backed; Sodium is required only by Ed25519
+DKIM. POSIX sendmail hardening is opportunistic and PCNTL is not part of the
+normal runtime graph.
+
+The security workflow contains a minimal-extension coldness gate that exercises
+these boundaries with unloadable gRPC, IMAP, and POSIX extensions disabled. It
+also enforces source-level confinement for compiled-in PCNTL and Sodium
+capabilities.
+
Module boundaries
-----------------
@@ -48,3 +95,5 @@ Every module includes fakes/assertion helpers and fake protocol servers where us
- gRPC: fake caller + retry tests.
- Webhook: signature/replay/redaction tests.
- Email: SMTP/IMAP/POP3/parser/bounce/authentication tests.
+- runtime: sequential/Fiber isolation, cancellation, and optional-capability
+ coldness gates.
diff --git a/docs/email/outbound.rst b/docs/email/outbound.rst
index 6fa9bcf..a5dc7e9 100644
--- a/docs/email/outbound.rst
+++ b/docs/email/outbound.rst
@@ -83,7 +83,7 @@ Important ``SmtpConfig`` options:
- ``authMechanism``: ``Auto``, ``Plain``, ``Login``, ``None``
- ``utf8Policy``: ``Reject``, ``Auto``, ``Require``
- ``allowEightBitMime``
-- ``captureTranscript`` (debug metadata)
+- ``captureTranscript`` (explicit diagnostic metadata; disabled by default)
- ``maxMessageBytes``
Per-recipient status
@@ -102,3 +102,45 @@ Behavior notes
- BCC recipients are part of envelope RCPT flow and are not written into message headers.
- Outbound builder normalizes line endings to CRLF.
- Streaming paths are used for large payload handling in SMTP/sendmail/spool transports.
+
+Sendmail process lifecycle
+--------------------------
+
+Sendmail execution uses an argument-array ``proc_open()`` path; no shell command
+string is constructed. The process lifetime is bounded by monotonic timeout and
+may receive a ``CancellationSignal`` through ``usingSendmail()``.
+
+TalkingBytes first requests graceful termination, waits a bounded grace period,
+then forces termination if required. On Unix, when the optional POSIX functions
+can successfully place the child into its own process group, termination targets
+that group so descendants are cleaned up as well. If process-group isolation is
+unavailable or cannot be established, TalkingBytes safely falls back to direct
+child termination.
+
+``ext-pcntl`` is not required and normal email transports do not install signal
+handlers. A host runtime should translate its own stop/signal policy into a
+``CancellationSignal``.
+
+Persistent mailbox ownership
+----------------------------
+
+IMAP and POP3 mailbox objects own their connection/session state. Reuse one
+instance only within the intended execution/worker scope. Long-running runtimes
+should call ``connect()`` explicitly when eager connection is useful and
+``logout()`` during deterministic scope cleanup; destructors remain best-effort
+shutdown protection.
+
+For watch loops, ``watchUntilCancelled()`` adapts the shared
+``CancellationSignal`` while the existing callable stop hook remains available.
+
+SMTP transcript diagnostics
+---------------------------
+
+``captureTranscript`` is an explicit opt-in debugging surface, not default
+observability. Client authentication payloads are replaced with ``[REDACTED]``
+and message DATA bodies are represented only by byte count.
+
+The transcript can still contain envelope addresses, non-authentication SMTP
+commands, capability text, and server response text. Treat it as sensitive
+diagnostic data, retain it only when necessary, and do not enable it as routine
+production logging.
diff --git a/docs/email/testing.rst b/docs/email/testing.rst
index cbbf24f..8735265 100644
--- a/docs/email/testing.rst
+++ b/docs/email/testing.rst
@@ -56,6 +56,10 @@ Spool tests include:
Event assertions
----------------
-Attach a listener via ``Email::events($listener)`` and assert event payload
-shape for ``email.send.*``, ``email.receive.*``, ``email.parse.failed``,
-``mailbox.command.*``, and ``bounce.detected``.
+Inject a ``CallableEventDispatcher`` into the sender/receiver/mailbox factory
+or parser under test and assert payload shape for ``email.send.*``,
+``email.receive.*``, ``email.parse.failed``, ``mailbox.command.*``, and
+``bounce.detected``.
+
+``Email::events()`` remains an explicit compatibility facade; normal runtime
+graphs do not read process-global listener state.
diff --git a/docs/events.rst b/docs/events.rst
index 276dfe1..fefbfb7 100644
--- a/docs/events.rst
+++ b/docs/events.rst
@@ -24,6 +24,16 @@ Inject a dispatcher
$client = HttpClient::curl($events);
+Email factories accept the same injected dispatcher:
+
+.. code-block:: php
+
+ use Infocyph\TalkingBytes\Email\Email;
+
+ $sender = Email::sender($events)->usingNull();
+ $receiver = Email::receiver($events);
+ $mailbox = Email::mailbox($events);
+
Compatibility adapter
---------------------
@@ -31,8 +41,9 @@ Compatibility adapter
\Infocyph\TalkingBytes\Core\Event\CommunicationEventBus::listen($listener);
-The static bus is retained for compatibility. Prefer constructor/factory
-injection in long-running workers and tests to avoid global state leakage.
+The static bus is retained only as an explicit compatibility facade. Normal
+protocol graphs do not consult it. Prefer constructor/factory injection in all
+new code, especially long-running workers and Fiber-based runtimes.
Event families
--------------
diff --git a/docs/extensions.rst b/docs/extensions.rst
index 7588730..131ac18 100644
--- a/docs/extensions.rst
+++ b/docs/extensions.rst
@@ -16,10 +16,42 @@ Suggested extensions/packages
- ``ext-grpc`` for native gRPC transport
- ``grpc/grpc`` for generated PHP gRPC clients
- ``ext-mbstring`` for robust charset conversion
+- ``ext-posix`` for best-effort Unix sendmail process-group cleanup
- ``ext-iconv`` as charset fallback when mbstring is unavailable
- ``ext-imap`` for optional address parsing and UTF7-IMAP fallback helpers
+- ``ext-sodium`` only for Ed25519-SHA256 DKIM signing and verification
+
+``ext-pcntl`` is deliberately neither required nor suggested. TalkingBytes does
+not install signal handlers or use fork-based protocol concurrency. Worker and
+supervisor signal ownership belongs to the host runtime.
+
+Cold capability behavior
+------------------------
+
+Optional capabilities are selected lazily.
+
+- HTTP and webhook graphs do not require or initialize native gRPC packages.
+- basic outbound email and SMTP composition do not require IMAP, POSIX, PCNTL,
+ or Sodium.
+- RSA-SHA256 DKIM uses OpenSSL and does not require Sodium.
+- Ed25519-SHA256 DKIM checks for Sodium only when that algorithm is selected and
+ fails with a clear runtime error when it is unavailable.
+- generated/native gRPC adapters are not probed by unrelated HTTP, webhook, or
+ email graphs.
+- IMAP helper fallbacks are evaluated only by mailbox code that needs them.
+- POSIX sendmail process-group hardening is opportunistic; the portable direct
+ child termination path remains available when POSIX functions are absent.
+
+The CI optional-capability coldness gate runs with unloadable ``ext-grpc``,
+``ext-imap``, and ``ext-posix`` disabled and exercises unrelated protocol
+graphs. Hosted PHP builds may compile PCNTL or Sodium in; the same gate therefore
+also enforces that no runtime PCNTL calls exist and that Sodium calls remain
+confined to the Ed25519 DKIM implementation.
Fallback behavior
-----------------
-Some parser/mailbox helpers degrade gracefully when optional extensions are unavailable. Check test skips in CI logs to detect extension-limited environments.
+Parser, mailbox, and process helpers degrade gracefully when optional extensions
+are unavailable. Optional capability failure must remain local to the feature
+that was explicitly selected; importing or constructing an unrelated protocol
+graph must not trigger extension or package probes.
diff --git a/docs/grpc/inbound-outbound.rst b/docs/grpc/inbound-outbound.rst
index b081e17..558d9d6 100644
--- a/docs/grpc/inbound-outbound.rst
+++ b/docs/grpc/inbound-outbound.rst
@@ -8,6 +8,7 @@ TalkingBytes now supports both directions:
- outbound client calls via ``GrpcClient``
- inbound request dispatch via ``GrpcInboundDispatcher``
+- host-controlled one-exchange execution via ``GrpcInboundSource`` and ``GrpcInboundExchange``
Module layout
-------------
@@ -15,7 +16,7 @@ Module layout
- ``src/Grpc/GrpcClient.php`` outbound entrypoint
- ``src/Grpc/Sender/*`` outbound request/response/transport models
- ``src/Grpc/GrpcInboundDispatcher.php`` inbound entrypoint
-- ``src/Grpc/Receiver/*`` inbound request/response/handler models
+- ``src/Grpc/Receiver/*`` inbound request/response/handler/source/exchange models
Outbound (Node A -> Node B)
---------------------------
@@ -99,11 +100,52 @@ Inbound (Node B request handling)
$response = $server->receive('/orders.v1.OrderService/Create', ['order_id' => 1001]);
+Host-controlled inbound runtime
+-------------------------------
+
+TalkingBytes intentionally does not own a forever-running server loop. A host such
+as Foundation can provide a ``GrpcInboundSource`` and call ``serveOne()``
+inside its existing worker lifecycle.
+
+.. code-block:: php
+
+ use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
+ use Infocyph\TalkingBytes\Grpc\GrpcInboundDispatcher;
+
+ $dispatcher = GrpcInboundDispatcher::new()
+ ->withHandler('/orders.v1.OrderService/Create', $handler);
+
+ $served = $dispatcher->serveOne(
+ $source,
+ CancellationSignal::fromCallable($hostShouldStop),
+ );
+
+The source owns waiting for/accepting one native exchange. The accepted exchange
+exposes a normalized ``GrpcInboundRequest`` and receives exactly one
+``GrpcInboundResponse``. TalkingBytes keeps dispatch/status/error semantics;
+the host keeps worker heartbeat, restart, release-generation and process policy.
+
+Inbound streaming scope in 2.1
+------------------------------
+
+The host-controlled inbound boundary in 2.1 is deliberately request/response:
+one accepted ``GrpcInboundExchange`` exposes one normalized
+``GrpcInboundRequest`` and is completed with exactly one
+``GrpcInboundResponse``.
+
+TalkingBytes 2.1 does **not** expose a server-side inbound streaming exchange
+contract. Client/server/bidirectional streaming support described in
+:doc:`streaming` belongs to the outbound/native client adapter boundary and
+processes iterables and callbacks incrementally without accumulating a complete
+stream in memory. A future inbound streaming contract, if added, must preserve
+the same incremental/bounded rule rather than buffering an entire stream.
+
Method dispatch behavior
------------------------
- unknown methods return ``GrpcStatus::Unimplemented``
-- handler exceptions return ``GrpcStatus::Internal``
+- handler exceptions return ``GrpcStatus::Internal`` with a stable public message
+- handler exception classes/messages/traces never cross the response boundary by default
- method and deadline are validated on inbound request construction
Inbound events
@@ -113,4 +155,6 @@ Inbound events
- ``grpc.inbound.finish``
- ``grpc.inbound.failed``
-This makes inbound request processing observable with the same shared event bus.
+Inbound observability is emitted through the injected dispatcher. Handler
+exception classes may appear in local failed-event diagnostics, but not in the
+wire response.
diff --git a/docs/grpc/native.rst b/docs/grpc/native.rst
index 93e3d43..4f5470b 100644
--- a/docs/grpc/native.rst
+++ b/docs/grpc/native.rst
@@ -32,19 +32,25 @@ Generated stub adapter
``GeneratedStubGrpcInvoker`` adapts generated ``grpc/grpc`` stub clients using
duck-typed call objects (``wait()``, ``responses()``/``read()``, ``write()``).
+Stream call shape is resolved once from public method reflection when the
+adapter is constructed. TalkingBytes never invokes a stream method merely to
+probe its signature, and a ``TypeError`` raised inside a generated/user stub is
+therefore never treated as a reason to invoke the method a second time.
+
+Explicit method maps are normalized and validated when the adapter is
+constructed. Missing, non-public, or invalid mapped methods fail before the
+first protocol call.
+
.. code-block:: php
use Infocyph\TalkingBytes\Grpc\GrpcClient;
- use Infocyph\TalkingBytes\Grpc\Native\GeneratedStubGrpcInvoker;
$stub = new \Orders\OrderServiceClient('orders.internal:443', [
'credentials' => \Grpc\ChannelCredentials::createSsl(),
]);
- $adapter = new GeneratedStubGrpcInvoker($stub, [
+ $client = GrpcClient::usingGeneratedStub($stub, [
'/orders.v1.OrderService/Create' => 'Create',
]);
- $client = GrpcClient::usingNativeStreaming($adapter, $adapter);
-
Method map keys are gRPC method paths; values are PHP stub method names.
diff --git a/docs/grpc/streaming.rst b/docs/grpc/streaming.rst
index 8b29ae8..f5c13ae 100644
--- a/docs/grpc/streaming.rst
+++ b/docs/grpc/streaming.rst
@@ -67,3 +67,16 @@ Use explicit binary metadata APIs:
- ``firstBinary('trace-bin')``
Non-binary metadata continues to use ``withValue()``, ``with()``, and ``values()``.
+
+Generated-stream cancellation
+-----------------------------
+
+``GeneratedStubGrpcInvoker`` accepts an optional ``CancellationSignal``.
+``GrpcClient::usingGeneratedStub()`` exposes the same optional cancellation
+argument.
+
+Cancellation is checked between outbound writes and inbound response
+deliveries. When cancellation or a consumer callback failure is observed,
+TalkingBytes invokes the native call object's ``cancel()`` method when
+available before propagating the failure. Streaming remains incremental; the
+adapter does not buffer a complete stream in memory.
diff --git a/docs/grpc/testing.rst b/docs/grpc/testing.rst
index 87dd09c..8d8d441 100644
--- a/docs/grpc/testing.rst
+++ b/docs/grpc/testing.rst
@@ -6,6 +6,8 @@ Utilities
- ``FakeGrpcCaller``
- ``AssertableGrpcCaller``
+- ``FakeGrpcInboundSource``
+- ``FakeGrpcInboundExchange``
Example
-------
@@ -24,3 +26,12 @@ Example
$fake->assert()->assertCallCount(1);
Assertions cover method, payload, metadata, and call count behavior.
+
+
+Inbound runtime testing
+-----------------------
+
+Queue a normalized inbound request with ``FakeGrpcInboundSource::enqueue()``,
+run one host cycle through ``GrpcInboundDispatcher::serveOne()``, then inspect
+the returned fake exchange for completion and response status/message. This keeps
+worker-loop tests deterministic without starting a socket server.
diff --git a/docs/http/concurrency.rst b/docs/http/concurrency.rst
index 563d4b8..9cdf4c5 100644
--- a/docs/http/concurrency.rst
+++ b/docs/http/concurrency.rst
@@ -32,7 +32,20 @@ Behavior
--------
- user-defined keys are preserved
-- max concurrency is enforced
+- max concurrency is enforced with a rolling window rather than fixed chunks
+- when one active handle completes, the next pending request is admitted immediately
- per-request configuration is respected
-- fail-fast mode is supported via request-pool options
-- cleanup runs for active handles on early stop/error paths
+- ``stopSchedulingOnFailure()`` stops only new admissions after a failure is observed; already-active requests are allowed to finish
+- active requests are not described as fail-fast unless explicit cancellation is supplied
+- cleanup runs for active handles on completion, cancellation, scheduler error, and early stop paths
+- pool duration uses the monotonic clock
+
+Cancellation
+------------
+
+Pass a ``CancellationSignal`` to ``HttpClient::multi(..., cancellation: $signal)``
+or use ``RequestPool::withCancellation()``.
+
+When cancellation is observed, the pool stops admitting requests, removes active
+cURL handles, aborts partial streamed-download temp files, and returns
+deterministic cancelled results for active and not-yet-started requests.
diff --git a/docs/index.rst b/docs/index.rst
index e71dde2..894bc2a 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -33,6 +33,7 @@ It provides:
events
middleware-and-resilience
+ resolved-composition
testing
security
performance
diff --git a/docs/performance.rst b/docs/performance.rst
index c43459d..dea5d07 100644
--- a/docs/performance.rst
+++ b/docs/performance.rst
@@ -6,7 +6,8 @@ HTTP
- use streaming download/upload for large payloads
- set max body/download/upload limits
-- use concurrent pool for high-latency fan-out
+- use the rolling concurrent pool for high-latency fan-out
+- scope mutable cookie/resilience collaborators deliberately in persistent workers
Email
-----
@@ -14,12 +15,30 @@ Email
- use streaming message build/send paths for large attachments
- use parser limits to bound inbound complexity
- use lazy IMAP part fetch where attachment payload is deferred
+- keep mailbox/socket connection state execution- or worker-owned
gRPC
----
- set deadlines per call
- enable retry only for idempotent/transient failures
+- scope generated/native stubs and channels according to the host lifecycle
+
+Persistent runtimes
+-------------------
+
+TalkingBytes does not use a process-global runtime registry for protocol state.
+Long-lived hosts should reuse immutable graphs and explicitly scope mutable
+collaborators such as cookie jars, resilience objects, mailbox sessions, native
+gRPC clients, fakes, and spies.
+
+Use ``CancellationSignal`` to adapt host stop/heartbeat/release state into
+retry, wait, stream, pool, mailbox, sendmail, and inbound gRPC boundaries.
+Elapsed durations and internal deadlines use monotonic time; wall-clock time is
+reserved for protocol semantics that require a real timestamp.
+
+Optional capabilities remain cold until selected. Do not preload native gRPC,
+IMAP, POSIX, PCNTL, or Sodium merely because another protocol is active.
General
-------
@@ -27,15 +46,41 @@ General
- keep retries bounded with backoff and jitter
- emit events/metrics for timing and failure analysis
- use fake transports for local and CI determinism
+- keep CPU microbenchmarks separate from network, disk, and child-process I/O
Benchmarks
----------
Run the repeatable component benchmark suite with ``composer ic:benchmark``.
-Record the PHP version, extensions, OPcache state, operating system, and
-hardware when comparing results. These microbenchmarks cover repeated HTTP
-request preparation, email building/parsing, and webhook verification; they do
-not establish production application RPM. Measure end-to-end sustained
-successful RPM separately on the production-equivalent host application and
-include concurrency, failures, timeouts, latency percentiles, and memory in the
-result.
+Record the PHP version, extensions, OPcache state, operating system, hardware,
+and peak memory where meaningful before comparing runs.
+
+The native suite includes:
+
+- HTTP request/auth preparation, immutable and resolved factory construction,
+ fake/cookie-enabled sends, and middleware/resilience composition
+- webhook signing, parsing, verification, replay claim, and duplicate rejection
+- gRPC request construction, unary callback dispatch, and inbound dispatch
+- email preparation, raw build/stream build, parser, null send, fake send, and
+ the existing 1/10/25 MB streaming payload cases
+- resilience primitive overhead
+
+The rolling cURL-multi scheduler is also covered by deterministic local-server
+tests that prove a freed concurrency slot is refilled before a slow peer
+completes. Treat end-to-end network/process benchmarks separately from CPU
+microbenchmarks.
+
+Soak evidence
+-------------
+
+``tests/RuntimeSoakTest.php`` repeatedly creates and releases protocol graphs
+and uses ``WeakReference`` plus garbage collection to detect accidental
+process-global retention. It also verifies that new mutable fake/replay graphs
+start with clean state. This is intentionally machine-independent; do not replace
+it with fragile absolute memory or timing thresholds.
+
+These benchmarks and soak tests do not establish production application RPM.
+Measure sustained successful RPM separately on the production-equivalent host
+application and include concurrency, failures, timeouts, latency percentiles,
+and memory in that result. Foundation owns direct-versus-TalkingBytes bridge
+attribution.
diff --git a/docs/release-checklist.rst b/docs/release-checklist.rst
index cd577e9..50ad210 100644
--- a/docs/release-checklist.rst
+++ b/docs/release-checklist.rst
@@ -15,23 +15,43 @@ Pre-release gates
- verify the supported PHP and dependency-version matrix is green in CI
- verify the Mailpit integration job is green
+- verify the optional-capability coldness job is green with unloadable gRPC,
+ IMAP, and POSIX extensions disabled
+- verify PCNTL has no runtime references and Sodium remains confined to the
+ Ed25519 DKIM implementation
+- verify no primary runtime path depends on ``CommunicationEventBus``
+- verify temporary process-global error handlers are restored and do not span
+ user callbacks, event dispatch, Fiber suspension, or long-lived waits
+- verify elapsed/deadline paths use monotonic time where protocol wall time is
+ not required
+- verify cancellation cleanup for retry, stream, pool, mailbox, and sendmail
+ paths
- verify no sensitive values in emitted events/log metadata
+- verify secret/PII sentinel tests remain green
- verify fake transports and smoke tests stay green
+- verify repeated-run soak checks show no unbounded handle/state growth
Protocol readiness
------------------
-- HTTP: streaming, limits, security guards, pool behavior validated
-- gRPC: retry and fake/native boundaries validated
-- Webhook: sign/verify/replay/redaction behavior validated
-- Email: SMTP/IMAP/POP3/parser/bounce/auth flows validated
+- HTTP: streaming, limits, security guards, rolling pool scheduling, cancellation
+ cleanup, and result ordering validated
+- gRPC: retry, generated/native adapter determinism, inbound accepted-exchange
+ boundary, streaming cleanup, and wire-error redaction validated
+- Webhook: sign/verify/replay/redaction behavior validated; replay claims are
+ atomic and fail closed by contract
+- Email: SMTP/IMAP/POP3/parser/bounce/auth flows validated; sendmail timeout and
+ portable/POSIX process cleanup validated
+- mutable cookie, resilience, mailbox, native-client, fake, and spy lifetimes
+ remain explicit
Documentation readiness
-----------------------
- README examples are current
-- docs/ pages reflect API and module boundaries
+- docs/ pages reflect API, lifetime, cancellation, and ownership boundaries
- extension requirements/suggestions are consistent with composer metadata
+- optional capability behavior matches the minimal-extension CI gate
- public documentation links resolve for the release version
- the versioning and compatibility boundary remains accurate
@@ -40,5 +60,9 @@ Versioning
- update changelog or release notes
- review the public API snapshot before accepting any breaking change
-- compare component benchmarks with the accepted baseline
-- tag only after CI, integration, release-guard, and documentation checks pass
+- compare native component benchmarks with the accepted baseline
+- record PHP version, extension set, OPcache state, operating system, hardware,
+ peak memory where meaningful, and benchmark class/methods used
+- freeze the exact release head before the final supported matrix
+- tag only the exact commit whose CI, integration, release-guard, benchmark,
+ soak, and documentation gates passed
diff --git a/docs/resolved-composition.rst b/docs/resolved-composition.rst
new file mode 100644
index 0000000..f89b05b
--- /dev/null
+++ b/docs/resolved-composition.rst
@@ -0,0 +1,80 @@
+Resolved Protocol Composition
+=============================
+
+Purpose
+-------
+
+Hosts such as Foundation often own named profiles, application paths, secret
+resolution, production policy, and DI lifetime. TalkingBytes should own the
+protocol mechanics after those values are resolved.
+
+The resolved composition APIs keep that boundary explicit:
+
+- ``HttpClient::fromResolvedConfig()`` applies HTTP auth, cookies, retry,
+ rate limiting, circuit breaking, and idempotency.
+- ``EmailSenderFactory::fromResolvedConfig()`` creates the selected transport
+ and composes fallbacks, retry, rate limiting, and DKIM.
+- ``EmailLimits::fromArray()`` parses parser limits natively.
+- ``GrpcClientFactory`` centralizes callable/native/generated client creation
+ and retry-profile application.
+- ``GrpcClient::usingGeneratedStub()`` is the direct generated-stub convenience
+ path.
+- ``Webhook::senderFromResolvedConfig()``,
+ ``Webhook::verifierFromResolvedConfig()`` and
+ ``Webhook::receiverFromResolvedConfig()`` apply resolved protocol policy.
+
+Host responsibilities
+---------------------
+
+The host should resolve these before calling TalkingBytes:
+
+- the selected profile name;
+- relative application paths;
+- secret source and production-secret policy;
+- DI lifetime and sharing policy;
+- application handler/service lookup;
+- concrete replay-store implementation.
+
+TalkingBytes does not become a named-profile repository or service container.
+
+HTTP
+----
+
+.. code-block:: php
+
+ $client = HttpClient::fromResolvedConfig([
+ 'timeoutSeconds' => 10,
+ 'auth' => [
+ 'driver' => 'bearer',
+ 'token' => $resolvedToken,
+ ],
+ 'retry' => [
+ 'enabled' => true,
+ 'attempts' => 3,
+ 'base_delay_ms' => 250,
+ 'max_retry_after_seconds' => 30,
+ ],
+ ]);
+
+Email
+-----
+
+``EmailSenderFactory::fromResolvedConfig()`` expects a resolved primary
+``transport`` array, optional resolved ``fallbacks`` arrays, and optional
+``retry``, ``rate_limit`` and ``dkim`` sections. File paths in log/spool/DKIM
+configuration should already be absolute or otherwise resolved by the host.
+
+gRPC
+----
+
+``GrpcClientFactory`` accepts the resolved retry section while the host supplies
+the callable/native/generated endpoint object. Generated stubs no longer need
+host code to construct ``GeneratedStubGrpcInvoker`` for ordinary use.
+
+Webhook
+-------
+
+Outbound resolved configuration can include ``signing_secret`` and ``retry``.
+Inbound resolved configuration can include ``max_age_seconds``,
+``max_payload_bytes`` and replay TTL/namespace. The replay-store object itself
+remains host-provided.
diff --git a/docs/security.rst b/docs/security.rst
index 64dcc66..0089bcd 100644
--- a/docs/security.rst
+++ b/docs/security.rst
@@ -39,3 +39,27 @@ Operational guidance
- Keep ``STARTTLS required`` for SMTP/IMAP in production.
- Use explicit limits for large mailbox scans and inbound parser workloads.
- Treat incoming authentication headers (SPF/DKIM/DMARC) as signal, not trust-on-first-use.
+
+Observability and data minimization
+-----------------------------------
+
+Default protocol events are intentionally less detailed than caller-facing
+``CommunicationResult`` diagnostics.
+
+- HTTP event headers redact authorization, proxy authorization, cookies, API
+ keys, auth tokens, and TalkingBytes webhook signatures.
+- HTTP and webhook failure events emit stable failure categories instead of raw
+ transport error strings.
+- Webhook events never include signing secrets, signature values, or payload bodies.
+- gRPC events emit method/status/count information and may include a local
+ exception class, but not metadata values, request bodies, exception messages,
+ traces, or remote wire exception details.
+- Email lifecycle/logging events use recipient counts and stable failure
+ categories; subjects, bodies, addresses, raw transport errors, and arbitrary
+ transport metadata are not copied into default observability.
+- Spool receiver events do not expose absolute paths or parsed subjects.
+- Mailbox authentication commands redact both usernames and credentials.
+
+Rich caller-facing errors remain available through returned result/response
+objects where the protocol API documents them. Apply application logging policy
+before recording those values.
diff --git a/docs/webhook/end-to-end.rst b/docs/webhook/end-to-end.rst
index 41dfe93..56a38aa 100644
--- a/docs/webhook/end-to-end.rst
+++ b/docs/webhook/end-to-end.rst
@@ -57,7 +57,7 @@ Replay protection
Security behavior
-----------------
-- signature format: ``t=,v1=``
+- signature format: ``t=,v2=`` (binds event and delivery ID)
- timestamp window enforced (default 300s)
- secret/signature/raw body are not emitted in event payloads
- reserved headers are sender-controlled:
diff --git a/docs/webhook/replay.rst b/docs/webhook/replay.rst
index fa5654d..df521aa 100644
--- a/docs/webhook/replay.rst
+++ b/docs/webhook/replay.rst
@@ -4,7 +4,10 @@ Replay Protection
Interface
---------
-Use ``WebhookReplayStore`` to prevent duplicate processing.
+Use ``WebhookReplayStore`` to prevent duplicate processing. The native receiver
+requires a ``v2`` signature authenticating both event and delivery ID before
+claiming replay state. Changing either header invalidates the signature.
+Backend atomicity alone cannot secure an unsigned delivery ID.
.. code-block:: php
@@ -31,5 +34,14 @@ smaller bound for constrained processes.
Production guidance
-------------------
-Use a Redis/database-backed atomic insert-if-absent operation with TTL. A
+Use a Redis/database-backed atomic insert-if-absent operation with TTL. The
+atomic claim must provide one-winner semantics across competing processes; a
separate check-then-write implementation is race-prone and is not sufficient.
+
+Replay backend errors are fail-closed. Implementations must throw when the
+atomic claim cannot be completed instead of treating an unavailable backend as
+an unused delivery ID.
+
+The built-in in-memory store is single-process only. It is appropriate for
+tests and local development, not for multi-worker or multi-node replay
+protection.
diff --git a/docs/webhook/sender.rst b/docs/webhook/sender.rst
index a6214a6..d477927 100644
--- a/docs/webhook/sender.rst
+++ b/docs/webhook/sender.rst
@@ -40,3 +40,32 @@ Reserved headers
Reserved webhook headers are sender-controlled and protected from override
so event/delivery/signature semantics stay authoritative.
+
+Signature interoperability
+--------------------------
+
+Signed native deliveries use ``t=,v2=``. For HMAC-SHA256,
+the signed bytes are the decimal timestamp, a dot, and the following bytes:
+
+.. code-block:: text
+
+ talkingbytes.webhook.v2eventdeliveryIdrawBody
+
+```` denotes one zero byte. Event and delivery ID cannot contain control
+characters; the body is included exactly as transmitted, without JSON
+re-encoding. Custom ``WebhookSigner`` implementations receive this bound payload
+and the timestamp. The native receiver verifies HMAC-SHA256.
+
+``WebhookSignature::buildHeader($body, $timestamp, $event, $deliveryId)``
+produces the same native signature. Its two-argument form retains legacy
+body/timestamp-only ``v1`` signing for direct integrations.
+
+Security upgrade from 2.0
+-------------------------
+
+Upgrade senders and receivers together. TalkingBytes 2.1 receivers reject
+legacy ``v1`` deliveries, and 2.0 receivers do not understand ``v2``. This is an
+intentional security compatibility correction: accepting unsigned delivery IDs
+allows replay protection to be bypassed, and unsigned event names allow event
+substitution. There is no automatic downgrade or insecure receiver opt-out.
+External senders must implement the byte format above before switching traffic.
diff --git a/docs/webhook/verifier-receiver.rst b/docs/webhook/verifier-receiver.rst
index c49680e..2e36d01 100644
--- a/docs/webhook/verifier-receiver.rst
+++ b/docs/webhook/verifier-receiver.rst
@@ -10,7 +10,14 @@ Verifier
- timestamp validity and tolerance window
- HMAC value with constant-time ``hash_equals``
-Signature format is ``t=,v1=``.
+Native delivery signature format is ``t=,v2=``. The signature
+binds the timestamp, event, delivery ID, and exact raw body. Receivers require
+``v2`` and never fall back to ``v1``.
+
+For direct verification, pass ``event`` and ``deliveryId`` to
+``verifyResult()``. Without those arguments, the low-level verifier retains
+legacy ``v1`` body/timestamp verification only; it does not authenticate routing
+or replay identity.
Verification result
-------------------
@@ -27,9 +34,9 @@ Receiver
``WebhookReceiver`` performs:
-1. verify signature
-2. decode JSON payload
-3. validate event + delivery id
+1. validate event + delivery id
+2. verify the signature including those fields
+3. decode JSON payload
4. optional replay store check
5. return ``WebhookEvent``
diff --git a/plan.md b/plan.md
deleted file mode 100644
index a29e595..0000000
--- a/plan.md
+++ /dev/null
@@ -1,3583 +0,0 @@
-# TalkingBytes — Final Next-Major Release Engineering Draft
-
-## Release Policy
-
-This is the final engineering specification for the next major release of `infocyph/talkingbytes`.
-
-### Breaking Changes
-
-Backward compatibility is **not required**.
-
-Prefer:
-
-```text
-correctness
-→ security
-→ protocol compliance
-→ performance
-→ scalability
-→ API clarity
-→ compatibility
-```
-
-Existing APIs may therefore be redesigned where necessary.
-
-### Feature Preservation
-
-This release is **not intended to drop TalkingBytes capabilities**.
-
-Existing supported/intended capabilities should remain and be improved.
-
-If an existing implementation or API is:
-
-- insecure;
-- misleading;
-- incomplete;
-- inefficient;
-- architecturally incorrect;
-
-replace/redesign it rather than unnecessarily removing the underlying capability.
-
-### PHPForge
-
-Keep exactly:
-
-```json
-"infocyph/phpforge": "dev-main@dev"
-```
-
-Do not replace it with a tagged PHPForge version.
-
-### Runtime Dependency Policy
-
-Keep TalkingBytes lightweight.
-
-Do not add ArrayKit, Intermix, CacheLayer, DBLayer, Omnibus, or another runtime dependency merely to replace small internal utilities.
-
-Add a runtime dependency only where it provides substantial functionality that TalkingBytes should not reasonably implement itself.
-
----
-
-# 1. Product Direction
-
-TalkingBytes should be a **protocol-aware PHP communication toolkit**.
-
-Its four primary communication domains are:
-
-```text
-Email
-HTTP
-Webhook
-gRPC
-```
-
-They should share only genuinely common infrastructure.
-
-Do not force every protocol through a universal communication abstraction.
-
----
-
-# 2. Protocol Responsibility
-
-## 2.1 Email
-
-Email is a complete **inbound + outbound mail communication system**.
-
-### Outbound
-
-```text
-Application
- ↓
-EmailMessage
- ↓
-Prepared MIME representation
- ↓
-DKIM/signing if configured
- ↓
-Transport
- ├── SMTP
- ├── sendmail
- ├── PHP mail()
- ├── spool
- ├── log
- ├── null
- └── fake
-```
-
-TalkingBytes should continue supporting:
-
-- From/To/Cc/Bcc;
-- Reply-To/Sender/Return-Path;
-- subject;
-- text;
-- HTML;
-- MIME alternatives;
-- attachments;
-- inline attachments;
-- templates;
-- DSN;
-- headers;
-- SMTP;
-- sendmail;
-- PHP `mail()`;
-- spool;
-- logging;
-- DKIM;
-- retries;
-- fallbacks;
-- rate limits;
-- testing/fakes.
-
-### Inbound
-
-```text
-Mail source
- ├── IMAP
- ├── POP3
- └── spool
- ↓
-Raw RFC822 message
- ↓
-Parser
- ├── headers
- ├── MIME
- ├── transfer decoding
- ├── charset decoding
- └── attachment extraction
- ↓
-ParsedEmail
- ├── body
- ├── attachments
- ├── DKIM
- ├── Authentication-Results
- ├── DSN
- └── bounce classification
-```
-
-TalkingBytes should continue supporting the full inbound chain.
-
-Do not reduce Email to only an SMTP sender.
-
----
-
-# 3. HTTP Responsibility
-
-HTTP remains the first-class **outbound HTTP client**.
-
-Primary flow:
-
-```text
-Application
- ↓
-HttpClient
- ↓
-HttpRequest
- ↓
-Request preparation
- ↓
-Security validation
- ↓
-CurlTransport / CurlMultiTransport
- ↓
-HTTP peer
- ↓
-HttpResponse
-```
-
-Primary use cases:
-
-- REST APIs;
-- external APIs;
-- service integrations;
-- authenticated APIs;
-- uploads;
-- downloads;
-- multipart;
-- concurrent requests;
-- signed requests;
-- API clients.
-
-TalkingBytes HTTP should **not become an HTTP web framework/server**.
-
-Do not add unrelated:
-
-- controllers;
-- routing;
-- web application middleware;
-- HTTP server lifecycle;
-- framework request handling.
-
----
-
-# 4. Webhook Responsibility
-
-Webhook remains specialized HTTP communication for:
-
-```text
-asynchronous callbacks
-+
-signed event delivery
-+
-verified event reception
-```
-
-Outbound:
-
-```text
-WebhookMessage
-→ delivery identity
-→ signature
-→ HTTP request
-→ retry
-→ result
-```
-
-Inbound:
-
-```text
-raw body + headers
-→ signature validation
-→ timestamp validation
-→ replay claim
-→ payload decoding
-→ WebhookEvent
-```
-
-Webhook should reuse the HTTP transport where appropriate without duplicating the HTTP client architecture.
-
----
-
-# 5. gRPC Responsibility
-
-gRPC should be a first-class **bidirectional service-to-service communication module**.
-
-It is particularly suited to internal services and microservices controlled by the same architecture.
-
-TalkingBytes should support both sides.
-
-## Outbound
-
-```text
-Service A
- ↓
-GrpcClient
- ↓
-GrpcRequest
- ↓
-metadata/deadline/policy
- ↓
-native/generated gRPC client
- ↓
-Service B
-```
-
-## Inbound
-
-```text
-native gRPC runtime
- ↓
-TalkingBytes inbound adapter/dispatcher
- ↓
-GrpcInboundRequest
- ↓
-application handler
- ↓
-GrpcInboundResponse
- ↓
-native runtime
-```
-
-gRPC should therefore remain:
-
-```text
-client
-+
-inbound dispatcher/adapter
-+
-request/response models
-+
-streaming
-+
-metadata
-+
-deadlines
-+
-retry
-+
-testing
-```
-
-Do not reduce it to an outbound client.
-
----
-
-# 6. Preferred Communication Roles
-
-Recommended architectural usage:
-
-```text
-Internal service RPC
- → gRPC
-
-External/general APIs
- → HTTP
-
-Asynchronous callback integrations
- → Webhook
-
-Mail infrastructure / users
- → Email
-```
-
-gRPC should generally be preferred for controlled internal microservices where:
-
-- contracts are shared;
-- protobuf/generated classes are acceptable;
-- low communication overhead matters;
-- persistent HTTP/2 connections help;
-- streaming is useful;
-- typed status/deadline semantics are valuable.
-
-HTTP remains preferable for broad interoperability and third-party APIs.
-
-Do not try to make one replace the other universally.
-
----
-
-# 7. gRPC Capability Model
-
-Preserve/support all meaningful RPC modes.
-
-## Unary
-
-```text
-request → response
-```
-
-## Server Streaming
-
-```text
-one request
-→ many responses
-```
-
-## Client Streaming
-
-```text
-many requests
-→ one response
-```
-
-## Bidirectional Streaming
-
-```text
-many requests
-↔
-many responses
-```
-
-Streaming must remain incremental and bounded.
-
-Do not convert large/infinite streams into arrays internally.
-
----
-
-# 8. gRPC Runtime Boundary
-
-TalkingBytes should **not implement the gRPC wire/network stack**.
-
-Leave:
-
-```text
-HTTP/2
-protobuf framing
-socket listening
-native channel management
-```
-
-to:
-
-- `ext-grpc`;
-- `grpc/grpc`;
-- generated stubs;
-- appropriate native/runtime integrations.
-
-TalkingBytes owns:
-
-- application-facing request/response objects;
-- method dispatch;
-- native adapters;
-- metadata;
-- deadlines;
-- retry policy;
-- error/status mapping;
-- streaming abstraction;
-- observability;
-- testing.
-
-Generated `.proto` classes remain external:
-
-```text
-.proto
-→ protoc
-→ generated PHP
-→ TalkingBytes integration
-```
-
----
-
-# 9. Core Architecture Redesign
-
-## 9.1 Shrink `Core`
-
-Core should contain only genuinely shared infrastructure.
-
-Target conceptually:
-
-```text
-Core/
-├── Event/
-├── Result/
-└── Support/
-```
-
-Do not place HTTP- or gRPC-specific concepts in Core merely to appear transport-neutral.
-
----
-
-# 10. Remove `CommunicationRequest`
-
-Current protocol requests already exist:
-
-```text
-HttpRequest
-GrpcRequest
-```
-
-Wrapping them inside a generic:
-
-```php
-CommunicationRequest
-```
-
-creates:
-
-- additional allocations;
-- `mixed` payloads;
-- duplicated options;
-- duplicated headers;
-- runtime `instanceof`;
-- weaker static analysis.
-
-For the next major:
-
-```text
-HttpRequest → HTTP pipeline
-GrpcRequest → gRPC pipeline
-```
-
-directly.
-
-Keep `CommunicationResult` if its shared result convention remains valuable.
-
-Do not replace `CommunicationRequest` with another generic envelope under a new name.
-
----
-
-# 11. Protocol-Specific Middleware
-
-Move protocol-aware policies to protocol modules.
-
-For example:
-
-```text
-Http/Middleware/
-Grpc/Middleware/
-```
-
-HTTP-specific:
-
-- authentication;
-- headers;
-- HTTP retry;
-- HTTP idempotency;
-- HTTP signing.
-
-gRPC-specific:
-
-- metadata;
-- RPC deadline;
-- RPC retry;
-- RPC idempotency.
-
-Only genuinely generic mechanics belong in Core.
-
----
-
-# 12. HTTP P0 — Secure Redirect Handling
-
-Do not rely on cURL automatic redirect following when destination security must be enforced.
-
-Current effective pattern can become:
-
-```text
-validated public URL
-→ redirect
-→ private destination contacted by cURL
-→ destination rejected afterward
-```
-
-That is too late.
-
-Implement TalkingBytes-owned redirect processing:
-
-```text
-validate current destination
-→ execute one request
-→ receive redirect
-→ resolve Location
-→ validate next destination
-→ execute next request
-```
-
-Validate **every redirect before connection**.
-
-Cover:
-
-- absolute redirects;
-- relative redirects;
-- scheme-relative redirects;
-- loops;
-- maximum redirects;
-- changed host;
-- changed port;
-- HTTPS → HTTP;
-- private destination;
-- blocked host;
-- allow-listed host.
-
----
-
-# 13. HTTP P0 — Remove Security-Bypassing cURL Options
-
-Do not allow unrestricted public:
-
-```php
-HttpRequest::option(CURLOPT_..., ...)
-```
-
-to override TalkingBytes-owned security/transport behavior.
-
-Arbitrary overrides can affect:
-
-- URL;
-- TLS;
-- redirect handling;
-- callbacks;
-- response limits;
-- upload handling;
-- proxy handling.
-
-Replace arbitrary cURL configuration with typed supported APIs.
-
-Remove/rework:
-
-```text
-CurlOptions::additional
-```
-
-Do not expose a generic unsafe escape hatch unless an unavoidable real-world requirement appears.
-
----
-
-# 14. HTTP P0 — DNS Rebinding / TOCTOU
-
-Avoid:
-
-```text
-security DNS lookup
-→ validation
-→ cURL performs independent DNS lookup
-```
-
-Resolve, validate and pin the destination where strict network protection is enabled.
-
-The actual connection should use an address that TalkingBytes already validated.
-
-Run the process again for every redirect.
-
----
-
-# 15. HTTP Private-Network Policy
-
-`blockPrivateNetworks()` should protect against more than RFC1918.
-
-Reject relevant non-public/special-use ranges including:
-
-- loopback;
-- private;
-- link-local;
-- shared address space;
-- multicast;
-- unspecified;
-- reserved;
-- documentation/special-use networks;
-- IPv6 equivalents.
-
-Define the policy as effectively:
-
-```text
-globally routable destinations only
-```
-
-when strict protection is enabled.
-
----
-
-# 16. HTTP Proxy Security
-
-Explicitly define the interaction between:
-
-```text
-proxy configuration
-+
-private-network blocking
-```
-
-A remote proxy may perform DNS resolution independently.
-
-Do not promise strict destination protection when TalkingBytes cannot control or verify proxy-side routing.
-
-Either:
-
-- support a proxy-aware enforcement design;
-- reject incompatible combinations;
-- or require explicit acknowledgement.
-
----
-
-# 17. HTTP Configuration Precedence
-
-Freeze this rule:
-
-```text
-explicit HttpRequest value
->
-HttpClient configured default
->
-library default
-```
-
-Apply consistently to:
-
-- timeout;
-- connect timeout;
-- redirects;
-- maximum redirects;
-- TLS;
-- CA;
-- proxy;
-- user-agent;
-- response limits;
-- upload/download limits.
-
-Do not detect "unset" by comparing values to library defaults.
-
-Represent explicit vs unset state correctly.
-
----
-
-# 18. HTTP Header Precedence
-
-Freeze:
-
-```text
-explicit request header
->
-client default header
-```
-
-Do not let `withDefaultHeaders()` overwrite request-specific headers.
-
-If a client-controlled mandatory header is needed, model it separately instead of calling it a default.
-
----
-
-# 19. Deterministic HTTP Request Preparation
-
-Request mutation must not depend on fluent configuration order.
-
-Required conceptual order:
-
-```text
-1. resolve client defaults
-2. finalize request query
-3. finalize body
-4. merge default/request headers
-5. apply ordinary authentication
-6. apply cookies
-7. establish idempotency identity
-8. finalize canonical URL/body
-9. sign
-10. transport
-```
-
-After signing, nothing that participates in the signature may mutate.
-
----
-
-# 20. HTTP Retry Safety
-
-Default automatic retry should be safe.
-
-Naturally retryable/idempotent methods may include:
-
-```text
-GET
-HEAD
-OPTIONS
-PUT
-DELETE
-```
-
-For:
-
-```text
-POST
-PATCH
-```
-
-require either:
-
-- a stable idempotency key;
-- explicit application opt-in.
-
-Do not merely document that retries should be used carefully.
-
-Enforce sensible behavior.
-
----
-
-# 21. Stable Idempotency
-
-A logical request must use exactly one idempotency key across all attempts.
-
-```text
-attempt 1
-=
-attempt 2
-=
-attempt N
-```
-
-Generate it before entering the retry loop.
-
-It must not depend on middleware registration order.
-
----
-
-# 22. Retry Policy Redesign
-
-Prefer stateless retry decisions over policy objects carrying hidden per-attempt mutable state.
-
-Conceptually:
-
-```php
-RetryDecision {
- bool retry;
- int delayMs;
-}
-```
-
-Evaluate using a retry context.
-
-Policy evaluation should be deterministic.
-
----
-
-# 23. Retry Arithmetic Safety
-
-All exponential/backoff calculations must be bounded.
-
-Prevent:
-
-```text
-INF
-NAN
-integer overflow
-float overflow
-negative delays
-unbounded Retry-After
-```
-
-before calling `usleep()`.
-
-Set sensible caps for:
-
-- attempts;
-- delay;
-- Retry-After.
-
----
-
-# 24. HTTP Repeatable Upload Sources
-
-Retryable uploads need repeatable sources.
-
-Suitable sources:
-
-```text
-file path
-seekable stream
-source factory
-bounded internal spool
-```
-
-Do not automatically retry an already-consumed non-seekable stream.
-
-For non-seekable streams:
-
-- reject;
-- or explicitly spool once within a configured limit.
-
----
-
-# 25. HTTP Multipart Ownership
-
-All internally generated temporary multipart files must have explicit ownership.
-
-Clean them in `finally`.
-
-Do not leave temporary files behind from:
-
-- `addData()`;
-- `addStream()`;
-- failed requests;
-- exceptions;
-- retries.
-
-Do not implement stream multipart as:
-
-```text
-stream
-→ complete PHP string
-→ temp file
-```
-
-for large input.
-
-Use incremental copying.
-
----
-
-# 26. Avoid Duplicate HTTP Body Serialization
-
-Signing, validation and transport should operate on one resolved body representation.
-
-Avoid:
-
-```text
-serialize for signature
-→ serialize again for cURL
-```
-
-For JSON/form/raw payloads, prepare once.
-
-Define multipart signing semantics explicitly.
-
----
-
-# 27. Reject URL Userinfo
-
-Reject URLs such as:
-
-```text
-https://user:password@example.com
-```
-
-Require dedicated authentication APIs.
-
-This avoids:
-
-- credential leakage;
-- URL reconstruction ambiguity;
-- log exposure.
-
----
-
-# 28. Preserve Query Semantics
-
-Do not round-trip arbitrary URLs through `parse_str()`.
-
-Preserve:
-
-```text
-?a=1&a=2
-```
-
-and ordered duplicate query entries.
-
-Introduce an ordered query representation where needed.
-
----
-
-# 29. HTTP Response Headers
-
-Treat HTTP header names case-insensitively.
-
-These must be semantically identical:
-
-```text
-Set-Cookie
-set-cookie
-SET-COOKIE
-```
-
-Preserve duplicate header values individually.
-
-Especially preserve multiple `Set-Cookie` fields.
-
----
-
-# 30. CookieJar Hardening
-
-Fix:
-
-## `Max-Age`
-
-`Max-Age` must override `Expires` regardless of attribute order.
-
-## Domain
-
-Do not claim browser-equivalent domain-cookie safety without a public-suffix strategy.
-
-Choose a conservative policy.
-
-Possible direction:
-
-- host-only by default;
-- explicit domain-cookie policy;
-- optional PSL support if justified.
-
-Keep:
-
-- path-boundary validation;
-- domain-origin validation;
-- cookie capacity bounds.
-
----
-
-# 31. HTTP Config Parsing
-
-Avoid dangerous casts such as:
-
-```php
-(bool) 'false'
-```
-
-Use strict configuration parsing.
-
-Invalid security-critical values should preferably throw instead of silently becoming defaults.
-
----
-
-# 32. CurlMulti Cleanup
-
-Every path must clean:
-
-- easy handles;
-- multi handle;
-- upload handles;
-- download temp resources;
-- multipart temp files.
-
-Use outer `finally`.
-
-Listener/observer exceptions must not leak resources.
-
----
-
-# 33. CurlMulti Status Handling
-
-Explicitly handle non-success:
-
-```php
-CURLM_*
-```
-
-results.
-
-Do not leave active requests with ambiguous result states.
-
----
-
-# 34. CurlMulti Select Handling
-
-Handle:
-
-```php
-curl_multi_select() === -1
-```
-
-using a bounded small sleep/backoff.
-
-Avoid CPU spinning.
-
----
-
-# 35. Pool Fail-Fast Semantics
-
-If `failFast` does not actually cancel active requests, either:
-
-- implement real fail-fast cancellation;
-- or rename/document it accurately.
-
-Do not promise stronger behavior than provided.
-
----
-
-# 36. HTTP Streaming Preserve
-
-Keep the strong streamed-download model:
-
-```text
-temporary destination
-→ bounded streaming
-→ atomic finalize
-```
-
-Also define:
-
-- existing-target replacement;
-- file permissions;
-- flushing;
-- cleanup on failure.
-
----
-
-# 37. Email P0 — Prepared Email Model
-
-This should be one of the largest architectural changes.
-
-Introduce an internal prepared/frozen email representation such as:
-
-```text
-PreparedEmail
-```
-
-or:
-
-```text
-MimePlan
-```
-
-Exact naming is implementation-defined.
-
-Preparation should freeze:
-
-- Date;
-- Message-ID;
-- MIME boundaries;
-- content types;
-- transfer encodings;
-- headers;
-- body sources;
-- attachment sources;
-- size information where available.
-
-Flow:
-
-```text
-EmailMessage
-→ prepare once
-→ optional DKIM
-→ transport exact prepared message
-```
-
----
-
-# 38. DKIM Must Sign Exact Wire Representation
-
-Do not:
-
-```text
-build A
-→ DKIM sign A
-→ rebuild B
-→ transmit B
-```
-
-because generated:
-
-- Date;
-- Message-ID;
-- MIME boundaries;
-
-may differ.
-
-Required invariant:
-
-```text
-DKIM signed bytes
-=
-transmitted bytes
-```
-
-for the canonicalized fields/body.
-
----
-
-# 39. Stable Email Identity
-
-For one logical send:
-
-```text
-wire Message-ID
-=
-reported Message-ID
-=
-DKIM Message-ID
-```
-
-Generated `Date` must likewise remain stable.
-
-Mime boundaries must not regenerate on retry/fallback.
-
----
-
-# 40. Remove Full Pre-Render SMTP Inspection
-
-Do not fully encode/render an email merely to discover:
-
-- size;
-- SMTPUTF8 requirement;
-- 8BITMIME requirement;
-- content characteristics.
-
-Use the prepared plan.
-
-Base64 size can be calculated from source size mathematically where available.
-
----
-
-# 41. Repeatable Email Attachment Sources
-
-Every attachment source involved in:
-
-- DKIM;
-- retry;
-- fallback;
-- inspection;
-- send;
-
-must be repeatable.
-
-Suitable source concepts include:
-
-```text
-data
-file
-seekable stream
-stream factory
-bounded spool
-```
-
-Keep the internal class count minimal.
-
----
-
-# 42. Non-Seekable Email Streams
-
-Do not silently treat arbitrary resources as retry-safe.
-
-For `attachStream()`:
-
-- require seekability;
-- accept a source factory;
-- or explicitly spool a non-seekable stream within limits.
-
-Do not let inspection exhaust the stream and then send an empty attachment.
-
----
-
-# 43. Stream Ownership
-
-Document and enforce:
-
-```text
-who owns the resource?
-who closes it?
-where does reading begin?
-can it be replayed?
-```
-
-TalkingBytes must not unexpectedly close caller-owned resources.
-
-Internally opened resources must always be closed.
-
----
-
-# 44. Stream Size
-
-Allow a known size to be supplied where appropriate.
-
-Use it for:
-
-- SMTP SIZE;
-- planning;
-- early rejection.
-
-Always validate actual bytes while reading.
-
----
-
-# 45. Email True Streaming
-
-Target:
-
-```text
-prepared MIME plan
-→ emit headers
-→ emit body parts
-→ encode attachments incrementally
-→ transport writer
-```
-
-Avoid complete MIME body materialization into `php://temp` followed by another read.
-
----
-
-# 46. Line Ending Normalization
-
-Do not normalize CR/LF independently for arbitrary chunks.
-
-A `\r\n` sequence can cross chunk boundaries.
-
-Either:
-
-- produce canonical CRLF upstream;
-- or use a stateful normalizer.
-
----
-
-# 47. `attachData()` Copies
-
-After correctness is established, optimize `attachData()` to avoid unnecessary complete temporary stream copies.
-
-Process large data strings incrementally where useful.
-
-Benchmark before micro-optimizing.
-
----
-
-# 48. Structural Email Headers
-
-Prevent public arbitrary custom headers from overriding protocol-owned structural headers such as:
-
-```text
-From
-To
-Cc
-Bcc
-Date
-Message-ID
-MIME-Version
-Content-Type
-Content-Transfer-Encoding
-DKIM-Signature
-```
-
-Dedicated APIs should own them.
-
-In particular, a custom `Bcc` header must never defeat envelope-only BCC behavior.
-
----
-
-# 49. Reply-To Behavior
-
-Do not implicitly create:
-
-```text
-Reply-To = From
-```
-
-when setting From.
-
-Mail clients already fall back to From when Reply-To is absent.
-
-Use:
-
-```text
-from()
-```
-
-only for From.
-
-Use:
-
-```text
-replyTo()
-```
-
-only when explicitly requested.
-
----
-
-# 50. Outbound Header Bounds
-
-Add:
-
-```text
-max total header bytes
-max header fields
-max header field bytes
-max physical line bytes
-```
-
-Outbound folding must remain protocol-safe.
-
-Reject unreasonably long unbreakable tokens.
-
----
-
-# 51. SMTP Local Domain Validation
-
-`localDomain` enters EHLO/HELO commands.
-
-Reject:
-
-- CR;
-- LF;
-- NUL;
-- control characters;
-- invalid whitespace;
-- invalid/oversized identity forms.
-
-Prevent SMTP command injection.
-
----
-
-# 52. SMTP Authentication Security
-
-Do not send credentials over plaintext accidentally.
-
-Reject:
-
-```text
-SmtpSecurity::None + credentials
-```
-
-and:
-
-```text
-StartTlsOptional
-+
-STARTTLS unavailable
-+
-credentials
-```
-
-Prefer no insecure-authentication override unless a genuine consumer requirement arises.
-
----
-
-# 53. SMTP AUTH Capability
-
-Do not automatically guess `AUTH LOGIN` when the server does not advertise a supported AUTH method.
-
-Fail explicitly.
-
----
-
-# 54. SMTP Response Bounds
-
-Bound:
-
-- line size;
-- multiline count;
-- total response bytes.
-
-Reject truncated/overlong lines.
-
----
-
-# 55. SMTP Deadlines
-
-Socket read timeout alone is insufficient.
-
-Implement an overall command/operation deadline.
-
-A slow-drip server must not keep an operation alive indefinitely.
-
----
-
-# 56. SMTP TLS
-
-Use explicit secure TLS stream settings:
-
-```text
-verify_peer
-verify_peer_name
-peer_name
-SNI
-secure crypto policy
-```
-
-Expose CA/client certificate settings where useful.
-
-Do not add an easy generic insecure switch.
-
----
-
-# 57. SMTP Transcript Redaction
-
-Keep the existing principle:
-
-- auth secret omitted;
-- DATA represented safely;
-- no credential leakage.
-
-Future debug features must preserve this.
-
----
-
-# 58. IMAP/POP3 Credential Guards
-
-Reject protocol command injection through username/password.
-
-At minimum reject:
-
-```text
-CR
-LF
-NUL
-ASCII controls
-```
-
-before command construction.
-
----
-
-# 59. IMAP/POP3 Authentication Security
-
-Do not silently send credentials over plaintext.
-
-Apply equivalent secure-auth rules to mailbox protocols.
-
----
-
-# 60. IMAP Literal Bounds
-
-Validate advertised literal size **before allocation/read**.
-
-Do not read a giant server-declared literal and only later apply `EmailLimits`.
-
----
-
-# 61. IMAP Response Bounds
-
-Bound:
-
-- line bytes;
-- number of response lines;
-- total response bytes;
-- literal bytes.
-
----
-
-# 62. POP3 Multiline Bounds
-
-Bound multiline responses while reading.
-
-Do not first accumulate an arbitrary message and only afterward give it to the parser.
-
----
-
-# 63. Protocol Line Truncation
-
-A bounded `fgets()` result that reached the line limit without termination should not be accepted as a complete valid protocol line.
-
-Reject explicitly.
-
----
-
-# 64. Mailbox Command Deadlines
-
-Apply overall deadlines to IMAP/POP commands.
-
-Account for IDLE/watch behavior separately.
-
----
-
-# 65. Mailbox Limits
-
-Propagate one coherent limit policy through:
-
-```text
-transport
-→ raw fetch
-→ parsed fetch
-→ attachments
-→ MIME parser
-```
-
-Do not instantiate unrelated default `EmailLimits` deep inside operations.
-
----
-
-# 66. Mailbox TLS
-
-Configure certificate verification explicitly for IMAP/POP.
-
-Include:
-
-- hostname verification;
-- SNI;
-- CA configuration when necessary.
-
----
-
-# 67. MIME Limits During Parsing
-
-Current safety limits should reject complexity **while constructing** the MIME tree.
-
-Track:
-
-- current depth;
-- total parts;
-- decoded bytes;
-- attachment bytes/count;
-
-during parsing.
-
-Do not fully construct pathological data before rejecting it.
-
----
-
-# 68. Incremental Decoding Limits
-
-Base64/quoted-printable/body decoding should enforce byte bounds incrementally.
-
-Do not decode a huge payload and reject afterward.
-
----
-
-# 69. Header Limit Semantics
-
-Differentiate:
-
-```text
-maxHeaderBytes
-maxHeaderFields
-maxHeaderLineBytes
-```
-
-Folded continuation lines are not separate header fields.
-
----
-
-# 70. Exact Raw Email Preservation
-
-`ParsedEmail::raw` should represent exact received bytes.
-
-Do not normalize it before storage.
-
-Normalized forms may be produced internally or stored separately.
-
-Exact raw preservation is critical for authentication/forensics.
-
----
-
-# 71. DKIM Raw Header Model
-
-Preserve raw signed header fields in addition to parsed/unfolded values.
-
-Conceptually:
-
-```text
-name
-raw bytes
-unfolded value
-```
-
-DKIM verification must use appropriate representation based on canonicalization.
-
----
-
-# 72. DKIM Canonicalization
-
-Correctly support/test:
-
-```text
-simple/simple
-simple/relaxed
-relaxed/simple
-relaxed/relaxed
-```
-
-Use independently generated fixtures.
-
-Do not validate only TalkingBytes-generated mail against TalkingBytes verification.
-
----
-
-# 73. DKIM Required Headers
-
-Ensure `From` participates in signed headers according to DKIM requirements.
-
-Reject invalid signing configuration early.
-
----
-
-# 74. DKIM Duplicate Header Semantics
-
-Repeated names in:
-
-```text
-h=
-```
-
-must consume header occurrences from the bottom correctly.
-
-Do not select the same final header repeatedly.
-
-Signer and verifier must agree.
-
----
-
-# 75. DKIM Algorithms
-
-TalkingBytes currently exposes RSA-SHA256 and Ed25519-SHA256 concepts.
-
-Because this release is not intended to drop capabilities:
-
-- fully implement Ed25519-SHA256 signing/verification where the PHP/OpenSSL/runtime capability permits;
-- detect unsupported runtime capability explicitly;
-- do not claim successful Ed25519 support on environments unable to provide it.
-
-RSA-SHA256 remains fully supported.
-
-Do not leave an enum/API option that can only throw because implementation is missing.
-
----
-
-# 76. Multiple DKIM Signatures
-
-Support messages containing more than one:
-
-```text
-DKIM-Signature
-```
-
-Provide per-signature results and convenient:
-
-```text
-any valid
-all results
-```
-
-semantics.
-
-This supports forwarding, transitions and key rotation.
-
----
-
-# 77. DKIM Resource Bounds
-
-Bound untrusted:
-
-- signature header bytes;
-- tag count;
-- `d=`;
-- `s=`;
-- `h=`;
-- `b=`;
-- `bh=`.
-
-Prevent excessive DNS/crypto/parser work.
-
----
-
-# 78. DKIM Key Records
-
-Handle important key-record semantics deliberately:
-
-- `v=`;
-- `k=`;
-- `p=`;
-- revoked/empty `p=`;
-- unsupported key types.
-
-Do not blindly wrap every `p=` value into a public key.
-
----
-
-# 79. DKIM Optional Tags
-
-Explicitly support or reject tags such as:
-
-```text
-l=
-x=
-```
-
-Do not silently appear RFC-complete while ignoring security-relevant semantics.
-
----
-
-# 80. DKIM Cache TTL
-
-`CachedDkimPublicKeyResolver` must not cache keys or missing records forever.
-
-Use bounded:
-
-- positive TTL;
-- negative TTL;
-- capacity.
-
-Do not add CacheLayer solely for this.
-
----
-
-# 81. DKIM Clock
-
-Use the internal Clock abstraction for signing timestamps.
-
-This improves deterministic tests.
-
----
-
-# 82. Spool Atomicity
-
-Keep:
-
-```text
-temporary/incomplete file
-→ complete write
-→ atomic rename
-```
-
-as the spool publishing model.
-
----
-
-# 83. Spool Size Precheck
-
-Check filesystem size before loading an entire `.eml` with `file_get_contents()`.
-
-Then enforce bounded read and parser limits.
-
----
-
-# 84. Spool Directory Scanning
-
-Avoid:
-
-```text
-glob entire directory
-→ sort
-→ choose one
-```
-
-for every message in `receiveMany()`.
-
-Use one bounded scan/batch operation where possible.
-
----
-
-# 85. Spool Producer Contract
-
-Document that producers must publish complete messages atomically.
-
-Receiver should consume only finalized extensions.
-
----
-
-# 86. Spool Permissions
-
-Use conservative filesystem permission behavior for:
-
-- mail contents;
-- metadata;
-- failed messages;
-- logs.
-
-Respect secure process `umask`.
-
----
-
-# 87. Failure Sidecars
-
-Bound and sanitize failure metadata.
-
-Do not persist arbitrarily large exception messages or control characters.
-
-Avoid secrets.
-
----
-
-# 88. Spool Directory Validation
-
-Prevent pathological overlaps between:
-
-```text
-source
-processing
-success
-failure
-```
-
-directories.
-
----
-
-# 89. Log Transport
-
-Stream large raw messages to the log file instead of always creating a complete PHP string first.
-
----
-
-# 90. Sendmail Output Bounds
-
-Drain process stdout/stderr to prevent blocking, but retain only bounded diagnostic data.
-
----
-
-# 91. Sendmail Timeout
-
-After timeout:
-
-```text
-terminate
-→ grace
-→ force termination if needed
-→ close
-```
-
-Do not allow `proc_close()` to block indefinitely.
-
----
-
-# 92. Email Event Redaction
-
-Do not blindly publish complete transport metadata in events.
-
-Use a centralized safe metadata allow-list/redactor.
-
----
-
-# 93. Observability Must Not Break Delivery
-
-An event listener/logger exception must not turn successful:
-
-- SMTP;
-- sendmail;
-- spool;
-- HTTP;
-- webhook;
-- gRPC;
-
-communication into failure by default.
-
----
-
-# 94. IMAP Parser Corpus
-
-Expand captured/pathological fixtures for:
-
-- quotes;
-- escaped strings;
-- NIL;
-- nested ENVELOPE;
-- address groups;
-- literals;
-- multiple FETCH values;
-- `BODY[]`;
-- `BODY.PEEK[]`;
-- partial sections;
-- nested BODYSTRUCTURE;
-- UTF7-IMAP;
-- delimiters;
-- untagged responses;
-- malformed literal declarations;
-- IDLE.
-
-Keep the native implementation rather than replacing it with `ext-imap`.
-
----
-
-# 95. Webhook Atomic Replay
-
-Replace non-atomic:
-
-```text
-seen()
-remember()
-```
-
-with atomic claim semantics.
-
-Conceptually:
-
-```php
-claim(namespace, deliveryId, ttl): bool
-```
-
-`true`:
-
-```text
-first claimant
-```
-
-`false`:
-
-```text
-already claimed
-```
-
-Support Redis/database implementations cleanly without forcing those dependencies into TalkingBytes.
-
----
-
-# 96. Replay Namespace
-
-Replay identity should include an endpoint/provider/application namespace.
-
-Do not globally identify deliveries solely by caller-supplied delivery ID.
-
----
-
-# 97. Replay Semantics Documentation
-
-Clearly state:
-
-```text
-replay claim
-≠
-exactly-once business processing
-```
-
-TalkingBytes prevents duplicate acceptance within the configured replay model.
-
-Application processing may still fail after acceptance.
-
----
-
-# 98. Multiple Webhook Signatures
-
-Support:
-
-```text
-t=timestamp,v1=signature1,v1=signature2
-```
-
-Verification succeeds when an allowed signature matches a configured secret.
-
----
-
-# 99. Webhook Secret Rotation
-
-Support bounded active secrets such as:
-
-```text
-current
-previous
-```
-
-without requiring consumers to build duplicate receiver pipelines.
-
----
-
-# 100. Webhook Signature Bounds
-
-Bound:
-
-- signature header bytes;
-- tag segments;
-- signature count;
-- malformed/invalid hex input.
-
----
-
-# 101. Webhook Payload Bounds
-
-Add/configure a maximum body size.
-
-Reject overly large input before expensive:
-
-- signature work;
-- JSON decoding;
-
-where possible.
-
----
-
-# 102. Webhook Identifier Guards
-
-Reject relevant ASCII control characters in event/delivery values.
-
-Keep sensible length bounds.
-
----
-
-# 103. One Webhook Retry Layer
-
-Do not allow:
-
-```text
-WebhookSender retries
-×
-HttpClient retries
-```
-
-to multiply network attempts invisibly.
-
-Webhook delivery should have one authoritative retry engine.
-
----
-
-# 104. Webhook Per-Attempt Signature Time
-
-For every actual network attempt:
-
-```text
-same delivery ID
-+
-new timestamp
-+
-new signature
-+
-correct attempt number
-```
-
-Do not sign once and retry hours later with a stale timestamp.
-
----
-
-# 105. Stable Webhook Delivery Identity
-
-Keep one logical delivery ID across attempts.
-
-This existing behavior is correct.
-
----
-
-# 106. Webhook Fake Parity
-
-Fake sender must validate the same message readiness requirements as production sender.
-
-A fake should not report success for input the real transport would reject.
-
----
-
-# 107. Webhook User-Agent
-
-Remove hardcoded:
-
-```text
-TalkingBytes/1.0
-```
-
-Use a correct stable version strategy.
-
-Avoid manually duplicating package version across the codebase.
-
----
-
-# 108. Event Architecture
-
-Remove global static dispatcher state as the main mechanism.
-
-Clients/senders should receive an `EventDispatcher`.
-
-Default:
-
-```text
-NullEventDispatcher
-```
-
-keeps disabled observability lightweight.
-
----
-
-# 109. Event Failure Policy
-
-Default dispatch should be best-effort.
-
-Observability is secondary to communication correctness.
-
-If strict/fail-fast event handling is ever needed, make it explicit.
-
----
-
-# 110. Resource Cleanup Before Event Failure
-
-All:
-
-- sockets;
-- cURL handles;
-- process resources;
-- temporary files;
-- streams;
-
-must be cleaned using `finally` independently of listener behavior.
-
----
-
-# 111. Remove Redundant Email Event Wrappers
-
-After the common event architecture is established, remove redundant implementation wrappers that provide no actual Email-specific behavior.
-
-This is an internal architecture cleanup, not a loss of email-event capability.
-
----
-
-# 112. Event Documentation
-
-Current docs referencing nonexistent/reset semantics must be aligned with the final actual API.
-
-Do not document methods that do not exist.
-
----
-
-# 113. Rate Limiter
-
-Replace O(n) timestamp-filtering hot paths with a bounded algorithm such as token bucket.
-
-Desired properties:
-
-```text
-O(1)-style hot path
-bounded memory
-monotonic timing
-deterministic tests
-```
-
-Keep this process-local.
-
-Do not force distributed persistence into TalkingBytes.
-
----
-
-# 114. Circuit Breaker
-
-Implement actual:
-
-```text
-Closed
-→ Open
-→ HalfOpen
-→ Closed/Open
-```
-
-semantics.
-
-After cooldown, permit only a limited probe—preferably one—to determine recovery.
-
-Prevent a thundering herd after cooldown.
-
-An enum is appropriate for circuit state.
-
----
-
-# 115. Internal Clock
-
-Introduce a small internal clock abstraction.
-
-Use where time controls behavior:
-
-- retry;
-- webhook timestamps;
-- replay;
-- DKIM;
-- rate limiter;
-- circuit breaker;
-- deadlines;
-- command timeouts.
-
-Use monotonic time for elapsed durations where appropriate.
-
----
-
-# 116. Internal Sleeper
-
-Centralize blocking sleep/backoff behavior.
-
-Allows deterministic testing and consistent overflow-safe sleeps.
-
-Keep it internal unless consumers genuinely require injection.
-
----
-
-# 117. HTTP Signing Namespace
-
-HTTP request signing is HTTP-specific.
-
-Move generic-looking signing infrastructure under:
-
-```text
-Http/Signing/
-```
-
-or an equally clear HTTP namespace.
-
-Webhook signing remains in Webhook.
-
-Do not generalize unrelated protocols solely because both use HMAC.
-
----
-
-# 118. HMAC Key Validation
-
-Reject empty HMAC keys.
-
----
-
-# 119. Signed Request Nonce/Timestamp
-
-Custom nonce generators must produce:
-
-```text
-non-empty
-bounded
-header-safe
-```
-
-values.
-
-Custom clocks must produce valid finite timestamps.
-
-Extension hooks must not bypass normal security guards.
-
----
-
-# 120. gRPC Internal Communication Role
-
-Treat gRPC as the main TalkingBytes option for efficient internal RPC where appropriate.
-
-Important benefits include:
-
-- generated typed contracts;
-- compact protobuf payloads;
-- HTTP/2 multiplexing;
-- persistent channels;
-- deadlines;
-- metadata;
-- canonical status codes;
-- streaming.
-
-Do not market it as universally superior to HTTP.
-
-Use it where its RPC model fits.
-
----
-
-# 121. gRPC Inbound Capability
-
-Keep inbound request handling first-class.
-
-TalkingBytes should support:
-
-```text
-native/runtime request
-→ inbound adapter
-→ method dispatch
-→ GrpcInboundRequest
-→ handler
-→ GrpcInboundResponse
-```
-
-This is an intentional package capability.
-
----
-
-# 122. `GrpcServer` Naming
-
-The current object acts more like an inbound dispatcher than a full socket/network gRPC server.
-
-Because compatibility is not required, consider a clearer name such as:
-
-```text
-GrpcInboundDispatcher
-```
-
-This is a naming/architecture correction only.
-
-**Do not remove inbound gRPC capability.**
-
----
-
-# 123. gRPC Retry Exceptions
-
-Do not automatically retry every thrown `Throwable`.
-
-Never retry programming/application problems by default, including:
-
-```text
-TypeError
-LogicException
-invalid request
-adapter contract errors
-application handler errors
-```
-
-Only retry explicitly classified transient transport/runtime failures.
-
----
-
-# 124. gRPC Retry Statuses
-
-Use conservative retry defaults.
-
-Do not assume statuses such as:
-
-```text
-Internal
-Aborted
-DeadlineExceeded
-```
-
-are universally retry-safe.
-
-Retry requires both:
-
-```text
-transient condition
-+
-retry-safe RPC semantics
-```
-
----
-
-# 125. gRPC RPC Idempotency
-
-gRPC method names do not automatically imply idempotency.
-
-Require explicit method/request retry safety for automatic retries.
-
-Do not accidentally retry:
-
-```text
-Charge
-Transfer
-CreateOrder
-```
-
-merely because a transport failure occurred.
-
----
-
-# 126. Overall gRPC Deadline
-
-A logical RPC deadline covers the entire operation.
-
-Do not restart a full deadline on every retry.
-
-Required:
-
-```text
-overall deadline
-→ attempt
-→ backoff consumes budget
-→ remaining deadline
-→ next attempt
-```
-
-Stop when the budget is exhausted.
-
----
-
-# 127. gRPC Deadline Validation
-
-Reject:
-
-```text
-NAN
-INF
--INF
-overflow
-invalid negative values
-```
-
-before seconds/microseconds conversion.
-
-Apply to:
-
-- unary request;
-- stream request;
-- inbound request;
-- deadline utilities.
-
----
-
-# 128. Deadline Propagation
-
-Support/enhance practical deadline propagation between internal services.
-
-Example:
-
-```text
-Service A has 900 ms remaining
-→ Service B receives remaining budget
-→ Service B does not create a new independent 3-second budget
-```
-
-This is important for real microservice latency control.
-
----
-
-# 129. gRPC Metadata
-
-Keep first-class metadata support for:
-
-- auth;
-- correlation IDs;
-- tracing;
-- tenant context;
-- service metadata;
-- binary values.
-
-Add bounds for:
-
-```text
-key count
-key length
-value length
-total metadata bytes
-```
-
----
-
-# 130. gRPC Status Semantics
-
-Preserve canonical gRPC status information.
-
-Do not reduce protocol result handling to only:
-
-```text
-success / error string
-```
-
-`CommunicationResult` may wrap a protocol-specific `GrpcResponse`, but gRPC status remains authoritative.
-
----
-
-# 131. gRPC Streaming Policy
-
-Unary middleware and streaming middleware/policies are not automatically equivalent.
-
-Make streaming behavior explicit.
-
-Do not claim that a unary retry/timeout middleware applies to all streaming operations when it does not.
-
----
-
-# 132. Streaming Retry Safety
-
-A generator/client stream may be non-repeatable.
-
-Do not automatically retry consumed client/bidi streams.
-
-Retry requires an explicitly repeatable message source.
-
----
-
-# 133. Streaming Backpressure
-
-Do not buffer entire gRPC streams by default.
-
-Process messages incrementally.
-
-Allow consumer callbacks/iterators to control flow where supported by the native runtime.
-
----
-
-# 134. gRPC Cancellation
-
-Where supported by the native gRPC runtime, expose cancellation/abort semantics.
-
-A caller abandoning work should be able to communicate that state to the peer.
-
-Keep this within native adapter capabilities rather than implementing protocol internals.
-
----
-
-# 135. Generated gRPC Adapter Validation
-
-Current runtime duck-typing is flexible but failures should happen early.
-
-Validate expected native call capabilities immediately when opening a:
-
-- unary;
-- server stream;
-- client stream;
-- bidi stream.
-
-Do not fail halfway through communication where avoidable.
-
----
-
-# 136. gRPC Method Identity
-
-Normalize method names internally to canonical:
-
-```text
-/package.Service/Method
-```
-
-Avoid mismatches between method maps and requests.
-
-Convenience input may accept variants if normalization is unambiguous.
-
----
-
-# 137. gRPC Inbound Exception Privacy
-
-Do not expose raw application exception messages to remote gRPC callers.
-
-Map internal failures to:
-
-```text
-INTERNAL
-+
-safe external message
-```
-
-Send details only to local observability.
-
----
-
-# 138. Configuration Philosophy
-
-Prefer typed constructors as the main API.
-
-Keep `fromArray()` where useful.
-
-For array configuration:
-
-- parse booleans strictly;
-- validate enums strictly;
-- reject malformed security settings;
-- avoid surprising fallback.
-
-Do not convert an explicit invalid configuration into another valid setting silently.
-
----
-
-# 139. Composer Runtime
-
-Keep runtime requirements lean.
-
-Current direction remains appropriate:
-
-```text
-PHP 8.4+
-ext-curl
-ext-fileinfo
-ext-openssl
-```
-
-Consider:
-
-```json
-"php": "^8.4"
-```
-
-if PHP 9 support is not explicitly tested.
-
-Otherwise retain `>=8.4` only with an intentional future-major CI policy.
-
----
-
-# 140. Optional Extensions
-
-Keep optional capabilities optional where appropriate:
-
-- `ext-grpc`;
-- `grpc/grpc`;
-- `ext-mbstring`;
-- `ext-iconv`;
-- `ext-imap`.
-
-TalkingBytes native mail parsing/mailbox behavior should not unnecessarily depend on `ext-imap`.
-
----
-
-# 141. PHPForge
-
-Final `composer.json` requirement:
-
-```json
-"require-dev": {
- "infocyph/phpforge": "dev-main@dev"
-}
-```
-
-Ensure repository, release branch and reviewed snapshot agree before tagging.
-
----
-
-# 142. Do Not Add Unnecessary Infocyph Dependencies
-
-Specifically do not add:
-
-```text
-ArrayKit
-Intermix
-CacheLayer
-DBLayer
-Omnibus
-```
-
-solely for:
-
-- configuration parsing;
-- simple maps;
-- tiny caches;
-- basic retry logic;
-- internal collections.
-
-Dependency-free core behavior remains a strength of TalkingBytes.
-
----
-
-# 143. Documentation — Architecture
-
-Update architecture docs to describe the actual final roles:
-
-```text
-Email
-→ inbound + outbound
-
-HTTP
-→ outbound HTTP client
-
-Webhook
-→ outbound + inbound signed callbacks
-
-gRPC
-→ outbound + inbound service RPC
-```
-
-Do not call protocol-aware middleware universally transport-agnostic.
-
----
-
-# 144. Documentation — HTTP Security
-
-Document:
-
-- manual redirect validation;
-- pre-connection destination checks;
-- DNS rebinding protection;
-- private/special-use policy;
-- proxy implications;
-- no arbitrary cURL bypass.
-
----
-
-# 145. Documentation — Retry
-
-Document separately:
-
-```text
-HTTP retry
-Webhook retry
-gRPC retry
-Email retry/fallback
-```
-
-Do not present all retry semantics as interchangeable.
-
-Document:
-
-- idempotency;
-- repeatable streams;
-- overall deadlines;
-- maximum delays.
-
----
-
-# 146. Documentation — Email Streaming
-
-Only call a path streaming if large content is actually processed incrementally.
-
-Document source repeatability and ownership.
-
----
-
-# 147. Documentation — SMTP Authentication
-
-Align docs with the real no-auth model.
-
-If:
-
-```text
-credentials === null
-```
-
-means no SMTP AUTH, document exactly that.
-
-Do not describe an enum value that does not exist.
-
----
-
-# 148. Documentation — DKIM
-
-Document:
-
-- supported algorithms;
-- runtime capability requirements;
-- canonicalizations;
-- multiple signatures;
-- DNS key caching;
-- validation bounds;
-- key rotation behavior.
-
----
-
-# 149. Documentation — Webhook Replay
-
-Clearly state replay protection guarantees and limitations.
-
----
-
-# 150. Documentation — gRPC
-
-Clearly state:
-
-- client capabilities;
-- inbound dispatcher capabilities;
-- streaming modes;
-- deadlines;
-- metadata;
-- native runtime boundary.
-
-Do not imply TalkingBytes itself implements an HTTP/2 gRPC network server if it does not.
-
----
-
-# 151. Documentation — Events
-
-Remove stale examples such as nonexistent event-bus reset APIs unless the final API actually implements them.
-
----
-
-# 152. HTTP Test Matrix
-
-Require tests for:
-
-## Defaults
-
-- explicit timeout beats client default;
-- redirects;
-- TLS;
-- proxy;
-- headers;
-- limits.
-
-## Preparation
-
-- auth then signing;
-- query auth + signing;
-- stable idempotency;
-- fluent-order independence.
-
-## Query
-
-- duplicate keys;
-- ordered duplicates;
-- fragments;
-- key edge cases;
-- userinfo rejection.
-
-## SSRF
-
-- localhost;
-- IPv4 private;
-- IPv6 private;
-- special-use;
-- DNS rebinding;
-- redirect to private;
-- redirect chains;
-- allow/block;
-- proxy policy.
-
-## Retry
-
-- safe method;
-- unsafe method;
-- idempotency;
-- transport classification;
-- Retry-After;
-- overflow.
-
-## Upload
-
-- file;
-- seekable stream;
-- non-seekable stream policy;
-- retry;
-- multipart cleanup.
-
-## Pool
-
-- multi errors;
-- select `-1`;
-- cleanup after exception;
-- fail-fast semantics.
-
-## Headers/Cookies
-
-- mixed-case duplicates;
-- Set-Cookie;
-- Max-Age order;
-- domain behavior;
-- path boundary.
-
----
-
-# 153. Email Test Matrix
-
-Require:
-
-## Prepared Email
-
-- Date stable;
-- Message-ID stable;
-- boundary stable;
-- reported/wire ID equal;
-- retries preserve representation;
-- fallback preserves representation.
-
-## Attachments
-
-- file;
-- data;
-- seekable stream;
-- non-seekable;
-- retry;
-- fallback;
-- DKIM;
-- size exact/over-limit;
-- inline;
-- 1/10/25 MB.
-
-## DKIM
-
-External fixtures for:
-
-```text
-simple/simple
-simple/relaxed
-relaxed/simple
-relaxed/relaxed
-```
-
-plus:
-
-- folds;
-- duplicates;
-- multiple signatures;
-- required From;
-- RSA;
-- Ed25519 where available;
-- invalid/revoked keys;
-- cache expiry;
-- oversized tags.
-
-## MIME
-
-- deep trees;
-- too many parts;
-- too many attachments;
-- oversized decoding;
-- malformed boundaries;
-- nested MIME;
-- exact raw preservation.
-
-## SMTP
-
-- EHLO injection;
-- plaintext auth rejection;
-- STARTTLS downgrade;
-- unsupported AUTH;
-- response limits;
-- total deadline;
-- transcript secrets.
-
-## IMAP/POP
-
-- credential injection;
-- insecure auth;
-- literal limits;
-- response limits;
-- overlong lines;
-- command deadlines;
-- TLS.
-
-## Spool/Sendmail/Log
-
-- atomic publication;
-- size precheck;
-- large directory;
-- failure handling;
-- sidecar limits;
-- output flooding;
-- timeout;
-- large streaming logs.
-
----
-
-# 154. Webhook Test Matrix
-
-Require:
-
-- valid HMAC;
-- invalid HMAC;
-- stale timestamp;
-- future boundary;
-- multiple `v1`;
-- current secret;
-- previous secret;
-- invalid secret;
-- oversized signature;
-- oversized payload;
-- malformed segments;
-- identifier control characters;
-- atomic concurrent replay claim;
-- replay namespace;
-- stable delivery ID;
-- new timestamp per attempt;
-- new signature per attempt;
-- one retry layer;
-- fake/real parity;
-- redaction.
-
----
-
-# 155. gRPC Test Matrix
-
-Require:
-
-## Unary
-
-- success;
-- status mapping;
-- transient transport retry;
-- programming error no retry;
-- idempotent retry;
-- unsafe retry rejected;
-- total deadline.
-
-## Deadline
-
-- zero;
-- negative;
-- NaN;
-- infinity;
-- overflow;
-- remaining deadline propagation.
-
-## Metadata
-
-- normal;
-- duplicate;
-- binary;
-- invalid keys;
-- control characters;
-- count limits;
-- value limits;
-- total limits.
-
-## Native Adapter
-
-- missing method;
-- invalid call shape;
-- unary;
-- server stream;
-- client stream;
-- bidi stream;
-- trailers/status.
-
-## Streaming
-
-- server;
-- client;
-- bidi;
-- non-repeatable stream retry rejection;
-- incremental consumption;
-- callback errors;
-- cancellation where runtime supports it.
-
-## Inbound
-
-- known method;
-- unknown method;
-- handler success;
-- handler exception;
-- no raw exception leakage;
-- event-listener behavior.
-
----
-
-# 156. Adversarial Test Corpus
-
-Maintain reusable corpora for:
-
-## HTTP
-
-- unusual IP forms;
-- IPv6;
-- redirects;
-- query encodings;
-- headers;
-- cookies;
-- hostile URLs.
-
-## Email
-
-- malformed MIME;
-- deep MIME;
-- huge boundaries;
-- malformed encodings;
-- duplicate headers;
-- folded headers;
-- charsets;
-- DSNs;
-- bounce messages;
-- DKIM variations;
-- IMAP transcripts;
-- POP3 responses.
-
-## Webhook
-
-- malformed tags;
-- duplicate tags;
-- oversized tags;
-- multiple secrets;
-- invalid signatures.
-
----
-
-# 157. Benchmark Expansion
-
-Keep existing PHPBench coverage and add:
-
-## HTTP
-
-- request creation;
-- HeaderBag;
-- QueryParams;
-- buildUrl;
-- 0/1/5/10 middleware/policies;
-- auth;
-- signing;
-- body serialization;
-- response collection;
-- multipart.
-
-## Email
-
-- plain build;
-- HTML;
-- multipart;
-- parse;
-- 1/10/25 MB attachment;
-- prepared email;
-- DKIM RSA;
-- DKIM Ed25519 where available;
-- cached/uncached verification.
-
-## Webhook
-
-- sign;
-- parse;
-- verify;
-- multiple secrets;
-- replay claim.
-
-## gRPC
-
-- request creation;
-- metadata;
-- unary adapter;
-- policy overhead;
-- deadline calculation.
-
-## Resilience
-
-- token bucket allow/reject;
-- breaker closed/open/half-open.
-
----
-
-# 158. End-to-End Performance Measurements
-
-Especially benchmark actual large payload behavior.
-
-## SMTP
-
-Test:
-
-```text
-1 MB
-10 MB
-25 MB
-```
-
-Measure:
-
-- wall time;
-- peak PHP memory;
-- bytes copied;
-- disk spill.
-
-## HTTP
-
-Measure:
-
-- large upload;
-- multipart;
-- large streamed download;
-- concurrent downloads.
-
-## Email Parser
-
-Measure realistic:
-
-- attachment-heavy mail;
-- MIME nesting;
-- malformed near-limit data.
-
----
-
-# 159. Optimization Priority
-
-Optimize in this order:
-
-```text
-remove repeated full payload work
-→ remove repeated serialization
-→ remove full-memory copies
-→ remove repeated scans
-→ bound allocations
-→ reduce unnecessary hot-path objects
-→ micro-optimize expressions
-```
-
-Do not increase architectural complexity for tiny benchmark wins.
-
----
-
-# 160. Release Validation
-
-Before tagging:
-
-```text
-composer validate --strict
-PHPForge full CI
-PHP lint
-static analysis
-style
-unit tests
-integration tests
-security audit
-benchmark comparison
-documentation build
-```
-
-Also verify a production-style consumer install:
-
-```bash
-composer install --no-dev --classmap-authoritative
-```
-
----
-
-# 161. Documentation Gate
-
-Run:
-
-```bash
-sphinx-build -W --keep-going -b html docs build/docs
-```
-
-Warnings fail the release.
-
-Verify all documentation examples match the actual final API.
-
----
-
-# 162. Optional Extension CI
-
-Test meaningful combinations:
-
-```text
-mbstring present/absent
-imap present/absent
-iconv where available
-grpc integration available
-```
-
-Graceful fallback behavior should actually be tested.
-
----
-
-# 163. Implementation Order
-
-## Phase 1 — Architecture
-
-- [ ] Freeze four protocol roles.
-- [ ] Preserve all existing intended capabilities.
-- [ ] Remove `CommunicationRequest`.
-- [ ] Introduce typed protocol pipelines.
-- [ ] Move protocol-specific middleware.
-- [ ] Redesign event injection.
-- [ ] Add Clock/Sleeper.
-- [ ] Clean signing namespace.
-
-## Phase 2 — HTTP Security
-
-- [ ] Remove unrestricted cURL options.
-- [ ] Implement manual redirects.
-- [ ] Resolve/validate/pin DNS.
-- [ ] Strengthen private/special-use blocking.
-- [ ] Define proxy policy.
-- [ ] Reject URL userinfo.
-- [ ] Add SSRF tests.
-
-## Phase 3 — HTTP Semantics
-
-- [ ] Fix client/request precedence.
-- [ ] Fix default-header precedence.
-- [ ] Replace lossy query parsing.
-- [ ] Freeze request preparation order.
-- [ ] Stable idempotency.
-- [ ] Idempotency-aware retry.
-- [ ] RetryDecision/stateless policy.
-- [ ] Overflow-safe backoff.
-
-## Phase 4 — HTTP Resources
-
-- [ ] Repeatable upload source model.
-- [ ] Multipart temp ownership.
-- [ ] Incremental multipart spooling.
-- [ ] Response header normalization.
-- [ ] Cookie fixes.
-- [ ] CurlMulti cleanup/status/select.
-- [ ] Correct fail-fast semantics.
-
-## Phase 5 — Prepared Email
-
-- [ ] Introduce prepared representation.
-- [ ] Freeze Date.
-- [ ] Freeze Message-ID.
-- [ ] Freeze MIME boundaries.
-- [ ] Freeze encodings.
-- [ ] Repeatable attachment sources.
-- [ ] Remove pre-render inspection.
-- [ ] Stream prepared content.
-
-## Phase 6 — DKIM
-
-- [ ] Exact-wire signing.
-- [ ] Raw inbound header model.
-- [ ] Correct canonicalizations.
-- [ ] Required From.
-- [ ] Duplicate header semantics.
-- [ ] Complete RSA support.
-- [ ] Complete Ed25519 support where runtime supports it.
-- [ ] Multiple signatures.
-- [ ] Key record validation.
-- [ ] Resource limits.
-- [ ] TTL cache.
-
-## Phase 7 — Email Protocol Security
-
-- [ ] SMTP EHLO guard.
-- [ ] SMTP secure authentication.
-- [ ] SMTP bounds/deadlines.
-- [ ] SMTP TLS.
-- [ ] IMAP credential guard.
-- [ ] POP3 credential guard.
-- [ ] secure mailbox authentication.
-- [ ] IMAP literal bounds.
-- [ ] POP3 response bounds.
-- [ ] mailbox deadlines.
-- [ ] mailbox TLS.
-
-## Phase 8 — Parser / Spool / Process
-
-- [ ] MIME limits during parse.
-- [ ] incremental decoded limits.
-- [ ] exact raw preservation.
-- [ ] header limits.
-- [ ] spool precheck.
-- [ ] spool scan optimization.
-- [ ] spool permissions.
-- [ ] sidecar bounds.
-- [ ] log streaming.
-- [ ] sendmail output bound.
-- [ ] sendmail timeout hardening.
-
-## Phase 9 — Webhook
-
-- [ ] Atomic replay claim.
-- [ ] Replay namespace.
-- [ ] Multiple signatures.
-- [ ] Secret rotation.
-- [ ] Signature limits.
-- [ ] Payload limit.
-- [ ] One retry layer.
-- [ ] Per-attempt timestamp/signature.
-- [ ] Fake parity.
-- [ ] Version/User-Agent cleanup.
-
-## Phase 10 — gRPC
-
-- [ ] Keep outbound client.
-- [ ] Keep/enhance inbound handling.
-- [ ] Clarify/rename inbound dispatcher.
-- [ ] Preserve unary.
-- [ ] Preserve server streaming.
-- [ ] Preserve client streaming.
-- [ ] Preserve bidi streaming.
-- [ ] Transient error classification.
-- [ ] RPC idempotency.
-- [ ] Overall deadline.
-- [ ] Deadline propagation.
-- [ ] Deadline finite checks.
-- [ ] Metadata limits.
-- [ ] Streaming policy behavior.
-- [ ] Streaming backpressure.
-- [ ] Cancellation integration.
-- [ ] Native-call validation.
-- [ ] Method normalization.
-- [ ] Error privacy.
-
-## Phase 11 — Resilience
-
-- [ ] Token-bucket rate limiter.
-- [ ] Closed/Open/HalfOpen breaker.
-- [ ] Single probe recovery.
-- [ ] Clock/Sleeper integration.
-
-## Phase 12 — Quality
-
-- [ ] Expand corpus tests.
-- [ ] Expand benchmarks.
-- [ ] Large-payload tests.
-- [ ] Integration tests.
-- [ ] Optional-extension matrix.
-
-## Phase 13 — Docs / Release
-
-- [ ] Rewrite architecture.
-- [ ] Rewrite protocol roles.
-- [ ] Rewrite security.
-- [ ] Rewrite retry.
-- [ ] Rewrite email streaming.
-- [ ] Rewrite DKIM.
-- [ ] Rewrite Webhook replay.
-- [ ] Rewrite gRPC service communication.
-- [ ] Fix stale APIs/examples.
-- [ ] Sync Composer.
-- [ ] Full PHPForge CI.
-- [ ] Sphinx warnings-as-errors.
-- [ ] Consumer-install test.
-- [ ] Benchmark acceptance.
-- [ ] Release.
-
----
-
-# 164. Final Architecture Acceptance
-
-The next major is architecturally complete only when:
-
-- [ ] Email remains full inbound + outbound.
-- [ ] HTTP remains a powerful outbound HTTP client.
-- [ ] Webhook remains inbound + outbound callback communication.
-- [ ] gRPC remains outbound + inbound service communication.
-- [ ] gRPC unary remains supported.
-- [ ] gRPC server streaming remains supported.
-- [ ] gRPC client streaming remains supported.
-- [ ] gRPC bidirectional streaming remains supported.
-- [ ] Current functionality is not removed merely to simplify architecture.
-- [ ] Unsafe functionality is redesigned rather than retained incorrectly.
-- [ ] No universal generic request object weakens protocol types.
-- [ ] Core contains only truly shared concerns.
-- [ ] gRPC network/wire runtime remains delegated to native gRPC tooling.
-- [ ] Internal services can communicate directly through gRPC without HTTP abstraction.
-- [ ] HTTP remains suitable for external/general integrations.
-- [ ] Webhook remains layered sensibly over HTTP.
-- [ ] Email inbound/outbound share appropriate infrastructure without collapsing into one incorrect model.
-
----
-
-# 165. Final Security Acceptance
-
-Do not release until:
-
-- [ ] Redirect destinations are validated before connection.
-- [ ] DNS rebinding protection exists under strict SSRF policy.
-- [ ] Arbitrary CURLOPT cannot bypass safety.
-- [ ] Proxy/security behavior is defined.
-- [ ] URL userinfo is rejected.
-- [ ] TLS defaults are secure.
-- [ ] SMTP authentication cannot silently downgrade.
-- [ ] IMAP/POP authentication cannot silently downgrade.
-- [ ] SMTP localDomain cannot inject commands.
-- [ ] Mailbox credentials cannot inject commands.
-- [ ] SMTP responses are bounded.
-- [ ] IMAP literals/responses are bounded.
-- [ ] POP3 responses are bounded.
-- [ ] MIME parser work is bounded during parsing.
-- [ ] Webhook replay is atomic.
-- [ ] Webhook signatures support rotation safely.
-- [ ] Inbound gRPC exceptions do not expose internal details.
-- [ ] Events/logs redact secrets.
-- [ ] Listener failures cannot bypass cleanup.
-
----
-
-# 166. Final Correctness Acceptance
-
-- [ ] Request overrides beat defaults.
-- [ ] Explicit headers beat default headers.
-- [ ] Signing always occurs after final request mutation.
-- [ ] Stable idempotency survives retry.
-- [ ] Unsafe requests do not retry accidentally.
-- [ ] Upload sources are actually repeatable before retry.
-- [ ] Multipart files cannot leak.
-- [ ] Duplicate HTTP headers behave case-insensitively.
-- [ ] Cookie precedence is correct.
-- [ ] DKIM signs the actual wire email.
-- [ ] Email Date is stable.
-- [ ] Email Message-ID is stable.
-- [ ] MIME boundaries are stable.
-- [ ] Reported Message-ID equals wire ID.
-- [ ] Non-seekable attachments cannot silently disappear.
-- [ ] Email retry/fallback cannot consume exhausted sources.
-- [ ] Raw inbound email is preserved exactly.
-- [ ] External DKIM fixtures pass.
-- [ ] RSA DKIM works.
-- [ ] Ed25519 DKIM works when supported.
-- [ ] Multiple DKIM signatures work.
-- [ ] Webhook retry timestamps remain valid.
-- [ ] Webhook retry count reflects real network attempts.
-- [ ] gRPC retry requires transient failure and retry-safe operation.
-- [ ] gRPC overall deadline does not reset per attempt.
-- [ ] Streaming semantics are explicit and truthful.
-
----
-
-# 167. Final Performance Acceptance
-
-- [ ] No unnecessary complete MIME pre-render before SMTP send.
-- [ ] Large email attachments stream incrementally.
-- [ ] Large HTTP uploads stream incrementally.
-- [ ] Multipart does not duplicate entire streams.
-- [ ] Parser limits prevent excessive allocation early.
-- [ ] Rate limiter hot path is bounded.
-- [ ] Circuit breaker hot path remains cheap.
-- [ ] Event-disabled paths remain cheap.
-- [ ] HTTP request preparation avoids repeated serialization.
-- [ ] Email preparation avoids repeated MIME generation.
-- [ ] gRPC streaming does not accumulate unbounded arrays.
-- [ ] 1/10/25 MB email benchmarks are recorded.
-- [ ] HTTP large transfer benchmarks are recorded.
-- [ ] Benchmark regressions are reviewed before release.
-
----
-
-# 168. Final Release Gate
-
-Before tagging:
-
-- [ ] `composer validate --strict`.
-- [ ] `infocyph/phpforge` remains `dev-main@dev`.
-- [ ] PHPForge full CI passes.
-- [ ] PHP lint passes.
-- [ ] Static analysis passes.
-- [ ] Style checks pass.
-- [ ] Unit tests pass.
-- [ ] Integration tests pass.
-- [ ] Security/adversarial tests pass.
-- [ ] Optional-extension matrix passes.
-- [ ] Documentation builds with warnings as errors.
-- [ ] Documentation examples match real APIs.
-- [ ] Benchmarks are recorded.
-- [ ] Large-payload benchmarks are accepted.
-- [ ] Clean production consumer installation passes.
-- [ ] Composer package/archive contents are inspected.
-- [ ] Public repository Composer metadata matches release source.
-- [ ] Runtime dependencies remain intentionally minimal.
-
----
-
-# 169. Explicit Non-Goals for This Major
-
-Do not broaden scope unnecessarily into:
-
-- HTTP application framework;
-- controllers/router;
-- full SMTP server;
-- custom gRPC wire implementation;
-- custom protobuf compiler;
-- Kafka;
-- AMQP;
-- generic queue system;
-- SMS;
-- push notifications;
-- Laravel-specific package;
-- Symfony-specific package;
-- service container;
-- automatic reflection/DI system;
-- generic distributed cache;
-- generic distributed circuit breaker;
-- template framework.
-
-These may be separate future projects or integrations.
-
----
-
-# 170. Final Product Definition
-
-After this major, TalkingBytes should be accurately described as:
-
-> **A high-performance, protocol-aware PHP communication toolkit providing complete email communication, secure HTTP client communication, authenticated webhook delivery/reception, and efficient bidirectional gRPC service communication.**
-
-The intended architectural roles are:
-
-```text
-Email
-→ complete inbound + outbound mail lifecycle
-
-HTTP
-→ secure, reliable external/general HTTP communication
-
-Webhook
-→ signed asynchronous HTTP callbacks in both directions
-
-gRPC
-→ typed internal service-to-service request/response and streaming
-```
-
-The guiding design rule is:
-
-> **Share infrastructure where semantics genuinely match; preserve protocol-specific models where they do not.**
-
-The guiding implementation rule is:
-
-> **Do not remove capability merely for architectural cleanliness. Redesign unsafe or inefficient implementations while retaining the useful capability.**
-
-The guiding performance rule is:
-
-> **Eliminate repeated work, copies, scans and unnecessary abstractions before pursuing micro-optimizations.**
-
-The guiding release rule is:
-
-> **A feature is not complete merely because its happy path works; its retry, malformed-input, large-payload, concurrency, resource-cleanup and security behavior must also be correct.**
-
-Once every applicable item in this specification is implemented, tested, benchmarked and documented, freeze the architecture and release the next major version.
\ No newline at end of file
diff --git a/src/Core/Event/BestEffortEventDispatcher.php b/src/Core/Event/BestEffortEventDispatcher.php
index 1a4039d..5bc5f28 100644
--- a/src/Core/Event/BestEffortEventDispatcher.php
+++ b/src/Core/Event/BestEffortEventDispatcher.php
@@ -10,6 +10,13 @@
{
public function __construct(private EventDispatcher $dispatcher) {}
+ public static function wrap(?EventDispatcher $dispatcher): self
+ {
+ return $dispatcher instanceof self
+ ? $dispatcher
+ : new self($dispatcher ?? new NullEventDispatcher());
+ }
+
/** @param array $payload */
public function dispatch(string $event, array $payload = []): void
{
diff --git a/src/Core/Support/CancellationSignal.php b/src/Core/Support/CancellationSignal.php
new file mode 100644
index 0000000..e184b18
--- /dev/null
+++ b/src/Core/Support/CancellationSignal.php
@@ -0,0 +1,35 @@
+requested = Closure::fromCallable($requested);
+ }
+
+ public static function fromCallable(callable $requested): self
+ {
+ return new self($requested);
+ }
+
+ public static function never(): self
+ {
+ return new self(static fn(): bool => false);
+ }
+
+ /** @phpstan-impure */
+ public function isRequested(): bool
+ {
+ return (bool) ($this->requested)();
+ }
+}
diff --git a/src/Core/Support/ObservabilitySanitizer.php b/src/Core/Support/ObservabilitySanitizer.php
new file mode 100644
index 0000000..bd49ca5
--- /dev/null
+++ b/src/Core/Support/ObservabilitySanitizer.php
@@ -0,0 +1,76 @@
+ */
+ private const array SAFE_METADATA_KEYS = [
+ 'accepted_count',
+ 'attempts',
+ 'cancelled',
+ 'duration_ms',
+ 'partial_success',
+ 'rejected_count',
+ 'started',
+ 'transport',
+ ];
+
+ /** @return array */
+ public static function resultContext(CommunicationResult $result): array
+ {
+ $context = [
+ 'successful' => $result->successful,
+ 'status_code' => $result->statusCode,
+ ];
+
+ if (!$result->successful) {
+ $context['failure_category'] = self::failureCategory($result);
+ }
+
+ foreach (self::SAFE_METADATA_KEYS as $key) {
+ if (!array_key_exists($key, $result->metadata)) {
+ continue;
+ }
+
+ $value = $result->metadata[$key];
+ if (is_scalar($value) || $value === null) {
+ $context[$key] = $value;
+ }
+ }
+
+ return $context;
+ }
+
+ /** @return array{failure_category:string,exception_class:class-string} */
+ public static function throwableContext(Throwable $throwable): array
+ {
+ return [
+ 'failure_category' => 'exception',
+ 'exception_class' => $throwable::class,
+ ];
+ }
+
+ private static function failureCategory(CommunicationResult $result): string
+ {
+ if (($result->metadata['cancelled'] ?? false) === true) {
+ return 'cancelled';
+ }
+ if (($result->metadata['scheduler_error'] ?? false) === true) {
+ return 'scheduler_error';
+ }
+ if ($result->statusCode !== null) {
+ return 'protocol_status';
+ }
+ if (is_string($result->metadata['exception'] ?? null)) {
+ return 'exception';
+ }
+
+ return 'transport_error';
+ }
+}
diff --git a/src/Core/Support/RetryExecutor.php b/src/Core/Support/RetryExecutor.php
index ebf5312..a2d94a7 100644
--- a/src/Core/Support/RetryExecutor.php
+++ b/src/Core/Support/RetryExecutor.php
@@ -18,11 +18,16 @@ public static function run(
RetryPolicy $policy,
callable $attempt,
?Sleeper $sleeper = null,
+ ?CancellationSignal $cancellation = null,
): CommunicationResult {
$sleeper ??= Sleeper::system();
$count = 1;
while (true) {
+ if ($cancellation?->isRequested() === true) {
+ return self::cancelled($count - 1);
+ }
+
try {
$result = $attempt();
} catch (Throwable $throwable) {
@@ -31,7 +36,9 @@ public static function run(
throw $throwable;
}
- $sleeper->milliseconds($decision->delayMs);
+ if (!self::wait($sleeper, $decision->delayMs, $cancellation)) {
+ return self::cancelled($count);
+ }
$count++;
continue;
@@ -42,8 +49,32 @@ public static function run(
return $result;
}
- $sleeper->milliseconds($decision->delayMs);
+ if (!self::wait($sleeper, $decision->delayMs, $cancellation)) {
+ return self::cancelled($count);
+ }
$count++;
}
}
+
+ private static function cancelled(int $attempts): CommunicationResult
+ {
+ return CommunicationResult::failure(
+ 'Operation cancelled.',
+ metadata: ['cancelled' => true, 'attempts' => max(0, $attempts)],
+ );
+ }
+
+ private static function wait(
+ Sleeper $sleeper,
+ int $delayMs,
+ ?CancellationSignal $cancellation,
+ ): bool {
+ if ($cancellation === null) {
+ $sleeper->milliseconds($delayMs);
+
+ return true;
+ }
+
+ return $sleeper->millisecondsInterruptibly($delayMs, $cancellation);
+ }
}
diff --git a/src/Core/Support/Sleeper.php b/src/Core/Support/Sleeper.php
index e804326..7032bf0 100644
--- a/src/Core/Support/Sleeper.php
+++ b/src/Core/Support/Sleeper.php
@@ -27,12 +27,7 @@ public static function system(): self
public function milliseconds(int $delayMs): void
{
- if ($delayMs < 0 || $delayMs > self::MAX_DELAY_MS) {
- throw new InvalidArgumentException(sprintf(
- 'Sleep delay must be between 0 and %d milliseconds.',
- self::MAX_DELAY_MS,
- ));
- }
+ $this->assertDelay($delayMs);
if ($delayMs === 0) {
return;
@@ -40,4 +35,41 @@ public function milliseconds(int $delayMs): void
($this->sleep)($delayMs * 1000);
}
+
+ public function millisecondsInterruptibly(
+ int $delayMs,
+ CancellationSignal $cancellation,
+ int $sliceMs = 50,
+ ): bool {
+ $this->assertDelay($delayMs);
+ if ($sliceMs < 1 || $sliceMs > 1000) {
+ throw new InvalidArgumentException('Interruptible sleep slice must be between 1 and 1000 milliseconds.');
+ }
+ if ($cancellation->isRequested()) {
+ return false;
+ }
+
+ $remaining = $delayMs;
+ while ($remaining > 0) {
+ $current = min($remaining, $sliceMs);
+ ($this->sleep)($current * 1000);
+ $remaining -= $current;
+
+ if ($cancellation->isRequested()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private function assertDelay(int $delayMs): void
+ {
+ if ($delayMs < 0 || $delayMs > self::MAX_DELAY_MS) {
+ throw new InvalidArgumentException(sprintf(
+ 'Sleep delay must be between 0 and %d milliseconds.',
+ self::MAX_DELAY_MS,
+ ));
+ }
+ }
}
diff --git a/src/Email/Config/DkimConfig.php b/src/Email/Config/DkimConfig.php
index 3868b24..406101b 100644
--- a/src/Email/Config/DkimConfig.php
+++ b/src/Email/Config/DkimConfig.php
@@ -53,6 +53,58 @@ public function __construct(
$this->assertPrivateKeyIsReadable($this->privateKey);
}
+ /**
+ * @param array $config
+ */
+ public static function fromArray(array $config): self
+ {
+ $domain = trim(ConfigValue::string($config, 'domain', ''));
+ $selector = trim(ConfigValue::string($config, 'selector', ''));
+ $algorithmName = ConfigValue::string($config, 'algorithm', DkimAlgorithm::RsaSha256->value);
+ $algorithm = DkimAlgorithm::tryFrom($algorithmName)
+ ?? throw new InvalidArgumentException('Unsupported DKIM algorithm.');
+
+ $headers = array_key_exists('headersToSign', $config)
+ ? ConfigValue::stringList($config, 'headersToSign', [])
+ : ConfigValue::stringList(
+ $config,
+ 'headers',
+ ['from', 'to', 'subject', 'date', 'message-id', 'mime-version', 'content-type'],
+ );
+
+ $privateKey = self::resolvedString($config, ['privateKey', 'private_key']);
+ $privateKeyPath = self::resolvedString($config, ['privateKeyPath', 'private_key_path']);
+ if ($privateKey !== null && $privateKeyPath !== null) {
+ throw new InvalidArgumentException('Configure either a DKIM private key or private key path, not both.');
+ }
+
+ if ($privateKeyPath !== null) {
+ if (!is_file($privateKeyPath) || !is_readable($privateKeyPath)) {
+ throw new InvalidArgumentException(sprintf('DKIM private key path is not readable: %s', $privateKeyPath));
+ }
+
+ $loaded = file_get_contents($privateKeyPath);
+ if (!is_string($loaded) || trim($loaded) === '') {
+ throw new InvalidArgumentException(sprintf('DKIM private key file is empty: %s', $privateKeyPath));
+ }
+ $privateKey = $loaded;
+ }
+
+ if ($privateKey === null) {
+ throw new InvalidArgumentException('DKIM signing requires a private key or private key path.');
+ }
+
+ return new self(
+ domain: $domain,
+ selector: $selector,
+ privateKey: $privateKey,
+ headersToSign: $headers,
+ algorithm: $algorithm,
+ headerCanonicalization: ConfigValue::string($config, 'headerCanonicalization', 'relaxed'),
+ bodyCanonicalization: ConfigValue::string($config, 'bodyCanonicalization', 'relaxed'),
+ );
+ }
+
/**
* @param list $headersToSign
*/
@@ -102,6 +154,27 @@ private static function assertDnsIdentifier(string $value, int $maximumBytes, st
}
}
+ /**
+ * @param array $config
+ * @param list $keys
+ */
+ private static function resolvedString(array $config, array $keys): ?string
+ {
+ foreach ($keys as $key) {
+ $value = $config[$key] ?? null;
+ if (!is_string($value)) {
+ continue;
+ }
+
+ $value = trim($value);
+ if ($value !== '') {
+ return $value;
+ }
+ }
+
+ return null;
+ }
+
private function assertPrivateKeyIsReadable(string $privateKey): void
{
if ($this->algorithm === DkimAlgorithm::Ed25519Sha256) {
diff --git a/src/Email/Config/EmailLimits.php b/src/Email/Config/EmailLimits.php
index c7663a7..e7ce701 100644
--- a/src/Email/Config/EmailLimits.php
+++ b/src/Email/Config/EmailLimits.php
@@ -30,6 +30,24 @@ public function __construct(
$this->assertPositive('maxHeaderLineBytes', $this->maxHeaderLineBytes);
}
+ /**
+ * @param array $config
+ */
+ public static function fromArray(array $config): self
+ {
+ return new self(
+ maxMessageBytes: ConfigValue::int($config, 'maxMessageBytes', 10_485_760),
+ maxAttachmentBytes: ConfigValue::int($config, 'maxAttachmentBytes', 26_214_400),
+ maxAttachmentCount: ConfigValue::int($config, 'maxAttachmentCount', 500),
+ maxDecodedBodyBytes: ConfigValue::int($config, 'maxDecodedBodyBytes', 10_485_760),
+ maxMimeDepth: ConfigValue::int($config, 'maxMimeDepth', 20),
+ maxMimeParts: ConfigValue::int($config, 'maxMimeParts', 500),
+ maxHeaderBytes: ConfigValue::int($config, 'maxHeaderBytes', 131_072),
+ maxHeaderCount: ConfigValue::int($config, 'maxHeaderCount', 2_000),
+ maxHeaderLineBytes: ConfigValue::int($config, 'maxHeaderLineBytes', 998),
+ );
+ }
+
private function assertPositive(string $name, int $value): void
{
if ($value > 0) {
diff --git a/src/Email/Email.php b/src/Email/Email.php
index e8a2720..73f5f70 100644
--- a/src/Email/Email.php
+++ b/src/Email/Email.php
@@ -5,6 +5,9 @@
namespace Infocyph\TalkingBytes\Email;
use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus;
+use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\Sleeper;
final readonly class Email
{
@@ -16,18 +19,26 @@ public static function events(?callable $listener): void
CommunicationEventBus::listen($listener);
}
- public static function mailbox(): EmailMailboxFactory
- {
- return new EmailMailboxFactory();
+ public static function mailbox(
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ?Sleeper $sleeper = null,
+ ): EmailMailboxFactory {
+ return new EmailMailboxFactory($events, $clock, $sleeper);
}
- public static function receiver(): EmailReceiverFactory
- {
- return new EmailReceiverFactory();
+ public static function receiver(
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ): EmailReceiverFactory {
+ return new EmailReceiverFactory($events, $clock);
}
- public static function sender(): EmailSenderFactory
- {
- return new EmailSenderFactory();
+ public static function sender(
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ?Sleeper $sleeper = null,
+ ): EmailSenderFactory {
+ return new EmailSenderFactory($events, $clock, $sleeper);
}
}
diff --git a/src/Email/EmailMailboxFactory.php b/src/Email/EmailMailboxFactory.php
index 4085cd1..9bab677 100644
--- a/src/Email/EmailMailboxFactory.php
+++ b/src/Email/EmailMailboxFactory.php
@@ -4,6 +4,11 @@
namespace Infocyph\TalkingBytes\Email;
+use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Email\Config\ImapConfig;
use Infocyph\TalkingBytes\Email\Config\Pop3Config;
use Infocyph\TalkingBytes\Email\Mailbox\Mailbox;
@@ -11,13 +16,29 @@
final readonly class EmailMailboxFactory
{
+ private Clock $clock;
+
+ private EventDispatcher $events;
+
+ private Sleeper $sleeper;
+
+ public function __construct(
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ?Sleeper $sleeper = null,
+ ) {
+ $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ $this->clock = $clock ?? Clock::system();
+ $this->sleeper = $sleeper ?? Sleeper::system();
+ }
+
public function usingImap(ImapConfig $config): Mailbox
{
- return Mailbox::usingImap($config);
+ return Mailbox::usingImap($config, $this->events, $this->clock, $this->sleeper);
}
public function usingPop3(Pop3Config $config): Pop3Mailbox
{
- return Pop3Mailbox::usingConfig($config);
+ return Pop3Mailbox::usingConfig($config, $this->events, $this->clock, $this->sleeper);
}
}
diff --git a/src/Email/EmailReceiverFactory.php b/src/Email/EmailReceiverFactory.php
index f5db998..ee0470e 100644
--- a/src/Email/EmailReceiverFactory.php
+++ b/src/Email/EmailReceiverFactory.php
@@ -4,6 +4,10 @@
namespace Infocyph\TalkingBytes\Email;
+use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\Clock;
use Infocyph\TalkingBytes\Email\Config\SpoolConfig;
use Infocyph\TalkingBytes\Email\Parser\EmailParser;
use Infocyph\TalkingBytes\Email\Parser\RawEmailParser;
@@ -11,6 +15,16 @@
final readonly class EmailReceiverFactory
{
+ private Clock $clock;
+
+ private EventDispatcher $events;
+
+ public function __construct(?EventDispatcher $events = null, ?Clock $clock = null)
+ {
+ $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ $this->clock = $clock ?? Clock::system();
+ }
+
public function usingSpool(
SpoolConfig $config,
?EmailParser $parser = null,
@@ -24,6 +38,8 @@ public function usingSpool(
$deleteAfterRead,
$moveAfterRead,
$failedDirectory,
+ $this->events,
+ $this->clock,
);
}
}
diff --git a/src/Email/EmailSenderFactory.php b/src/Email/EmailSenderFactory.php
index 2356847..73eee5b 100644
--- a/src/Email/EmailSenderFactory.php
+++ b/src/Email/EmailSenderFactory.php
@@ -4,45 +4,217 @@
namespace Infocyph\TalkingBytes\Email;
+use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\Sleeper;
+use Infocyph\TalkingBytes\Email\Config\ConfigValue;
+use Infocyph\TalkingBytes\Email\Config\DkimConfig;
use Infocyph\TalkingBytes\Email\Config\LogEmailConfig;
use Infocyph\TalkingBytes\Email\Config\SendmailConfig;
use Infocyph\TalkingBytes\Email\Config\SmtpConfig;
use Infocyph\TalkingBytes\Email\Config\SpoolConfig;
+use Infocyph\TalkingBytes\Resilience\RateLimiter;
+use Infocyph\TalkingBytes\Retry\ExponentialBackoffRetryPolicy;
+use Infocyph\TalkingBytes\Retry\FixedDelayRetryPolicy;
+use InvalidArgumentException;
final readonly class EmailSenderFactory
{
+ private Clock $clock;
+
+ private EventDispatcher $events;
+
+ private Sleeper $sleeper;
+
+ public function __construct(
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ?Sleeper $sleeper = null,
+ ) {
+ $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ $this->clock = $clock ?? Clock::system();
+ $this->sleeper = $sleeper ?? Sleeper::system();
+ }
+
public function fake(): Emailer
{
- return Emailer::fake();
+ return Emailer::fake($this->events, $this->clock);
+ }
+
+ /**
+ * Build an email sender from already-resolved protocol configuration.
+ *
+ * Expected top-level sections are transport, fallbacks, retry, rate_limit and dkim.
+ * Paths and secrets must already reflect host policy before reaching this boundary.
+ *
+ * @param array $config
+ */
+ public function fromResolvedConfig(
+ array $config,
+ ?CancellationSignal $cancellation = null,
+ ): Emailer {
+ $transport = self::section($config, 'transport', required: true);
+ $emailer = $this->usingResolvedTransport($transport, $cancellation);
+
+ $fallbackTransports = [];
+ foreach (self::sections($config, 'fallbacks') as $fallback) {
+ $fallbackTransports[] = $this->usingResolvedTransport($fallback, $cancellation)->transport();
+ }
+ if ($fallbackTransports !== []) {
+ $emailer = $emailer->withFallback($fallbackTransports);
+ }
+
+ $retry = self::section($config, 'retry');
+ if (ConfigValue::bool($retry, 'enabled', false)) {
+ $attempts = ConfigValue::int($retry, 'max_attempts', 3);
+ $delayMs = ConfigValue::int($retry, 'delay_ms', 250);
+ $policy = match (ConfigValue::string($retry, 'policy', 'fixed')) {
+ 'backoff', 'exponential' => new ExponentialBackoffRetryPolicy($attempts, $delayMs),
+ 'fixed' => new FixedDelayRetryPolicy($attempts, $delayMs),
+ default => throw new InvalidArgumentException('Unsupported email retry policy.'),
+ };
+ $emailer = $emailer->withRetry($policy, $cancellation);
+ }
+
+ $rateLimit = self::section($config, 'rate_limit');
+ if (ConfigValue::bool($rateLimit, 'enabled', false)) {
+ $emailer = $emailer->withRateLimit(new RateLimiter(
+ ConfigValue::int($rateLimit, 'max_requests', 60),
+ ConfigValue::int($rateLimit, 'per_seconds', 60),
+ ));
+ }
+
+ $dkim = self::section($config, 'dkim');
+ if (ConfigValue::bool($dkim, 'enabled', false)) {
+ $emailer = $emailer->withDkim(DkimConfig::fromArray($dkim));
+ }
+
+ return $emailer;
}
public function usingLog(LogEmailConfig $config): Emailer
{
- return Emailer::usingLog($config);
+ return Emailer::usingLog($config, $this->events, $this->clock);
}
public function usingMailFunction(): Emailer
{
- return Emailer::usingMailFunction();
+ return Emailer::usingMailFunction($this->events, $this->clock);
}
public function usingNull(): Emailer
{
- return Emailer::usingNull();
+ return Emailer::usingNull($this->events, $this->clock);
}
- public function usingSendmail(SendmailConfig $config = new SendmailConfig()): Emailer
- {
- return Emailer::usingSendmail($config);
+ public function usingSendmail(
+ SendmailConfig $config = new SendmailConfig(),
+ ?CancellationSignal $cancellation = null,
+ ): Emailer {
+ return Emailer::usingSendmail(
+ $config,
+ $this->events,
+ $this->clock,
+ $cancellation,
+ $this->sleeper,
+ );
}
public function usingSmtp(SmtpConfig $config): Emailer
{
- return Emailer::usingSmtp($config);
+ return Emailer::usingSmtp($config, $this->events, $this->clock);
}
public function usingSpool(SpoolConfig $config): Emailer
{
- return Emailer::usingSpool($config);
+ return Emailer::usingSpool($config, $this->events, $this->clock);
+ }
+
+ /**
+ * @param array $config
+ * @return array
+ */
+ private static function section(array $config, string $key, bool $required = false): array
+ {
+ $value = $config[$key] ?? null;
+ if ($value === null && !$required) {
+ return [];
+ }
+
+ if (!is_array($value)) {
+ throw new InvalidArgumentException(sprintf('Email resolved configuration section "%s" must be an array.', $key));
+ }
+
+ $section = [];
+ foreach ($value as $name => $item) {
+ if (is_string($name)) {
+ $section[$name] = $item;
+ }
+ }
+
+ if ($required && $section === []) {
+ throw new InvalidArgumentException(sprintf('Email resolved configuration section "%s" must not be empty.', $key));
+ }
+
+ return $section;
+ }
+
+ /**
+ * @param array $config
+ * @return list>
+ */
+ private static function sections(array $config, string $key): array
+ {
+ $value = $config[$key] ?? [];
+ if (!is_array($value)) {
+ throw new InvalidArgumentException(sprintf('Email resolved configuration section "%s" must be a list.', $key));
+ }
+
+ $sections = [];
+ foreach ($value as $item) {
+ if (!is_array($item)) {
+ throw new InvalidArgumentException(sprintf('Email resolved configuration section "%s" must contain arrays.', $key));
+ }
+
+ $section = [];
+ foreach ($item as $name => $entry) {
+ if (is_string($name)) {
+ $section[$name] = $entry;
+ }
+ }
+ $sections[] = $section;
+ }
+
+ return $sections;
+ }
+
+ /**
+ * @param array $config
+ */
+ private function usingResolvedTransport(
+ array $config,
+ ?CancellationSignal $cancellation,
+ ): Emailer {
+ $driver = trim(ConfigValue::string($config, 'driver', ''));
+ if ($driver === '') {
+ throw new InvalidArgumentException('Resolved email transport driver must be non-empty.');
+ }
+
+ return match ($driver) {
+ 'fake' => $this->fake(),
+ 'log' => $this->usingLog(LogEmailConfig::fromArray($config)),
+ 'mail' => $this->usingMailFunction(),
+ 'null' => $this->usingNull(),
+ 'sendmail' => $this->usingSendmail(SendmailConfig::fromArray($config), $cancellation),
+ 'smtp' => $this->usingSmtp(SmtpConfig::fromArray($config)),
+ 'spool' => $this->usingSpool(SpoolConfig::fromArray($config)),
+ default => throw new InvalidArgumentException(sprintf(
+ 'Unsupported resolved email transport driver "%s".',
+ $driver,
+ )),
+ };
}
}
diff --git a/src/Email/Emailer.php b/src/Email/Emailer.php
index c3da30a..35b4c96 100644
--- a/src/Email/Emailer.php
+++ b/src/Email/Emailer.php
@@ -8,6 +8,10 @@
use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer;
+use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Email\Config\DkimConfig;
use Infocyph\TalkingBytes\Email\Config\LogEmailConfig;
use Infocyph\TalkingBytes\Email\Config\SendmailConfig;
@@ -33,46 +37,66 @@
final readonly class Emailer
{
+ private Clock $clock;
+
private EventDispatcher $events;
- public function __construct(private EmailTransport $transport, ?EventDispatcher $events = null)
- {
+ public function __construct(
+ private EmailTransport $transport,
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ) {
$this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ $this->clock = $clock ?? Clock::system();
}
- public static function fake(): self
+ public static function fake(?EventDispatcher $events = null, ?Clock $clock = null): self
{
- return new self(new FakeEmailTransport());
+ return new self(new FakeEmailTransport(), $events, $clock);
}
- public static function usingLog(LogEmailConfig $config): self
+ public static function usingLog(LogEmailConfig $config, ?EventDispatcher $events = null, ?Clock $clock = null): self
{
- return new self(new LogEmailTransport($config));
+ return new self(new LogEmailTransport($config), $events, $clock);
}
- public static function usingMailFunction(): self
+ public static function usingMailFunction(?EventDispatcher $events = null, ?Clock $clock = null): self
{
- return new self(new MailFunctionTransport());
+ return new self(new MailFunctionTransport(), $events, $clock);
}
- public static function usingNull(): self
+ public static function usingNull(?EventDispatcher $events = null, ?Clock $clock = null): self
{
- return new self(new NullEmailTransport());
+ return new self(new NullEmailTransport(), $events, $clock);
}
- public static function usingSendmail(SendmailConfig $config = new SendmailConfig()): self
- {
- return new self(new SendmailTransport($config));
+ public static function usingSendmail(
+ SendmailConfig $config = new SendmailConfig(),
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ?CancellationSignal $cancellation = null,
+ ?Sleeper $sleeper = null,
+ ): self {
+ return new self(
+ new SendmailTransport(
+ $config,
+ cancellation: $cancellation,
+ clock: $clock,
+ sleeper: $sleeper,
+ ),
+ $events,
+ $clock,
+ );
}
- public static function usingSmtp(SmtpConfig $config): self
+ public static function usingSmtp(SmtpConfig $config, ?EventDispatcher $events = null, ?Clock $clock = null): self
{
- return new self(new SmtpTransport($config));
+ return new self(new SmtpTransport($config, clock: $clock), $events, $clock);
}
- public static function usingSpool(SpoolConfig $config): self
+ public static function usingSpool(SpoolConfig $config, ?EventDispatcher $events = null, ?Clock $clock = null): self
{
- return new self(new SpoolEmailTransport($config));
+ return new self(new SpoolEmailTransport($config), $events, $clock);
}
public function assertable(): AssertableEmailTransport
@@ -87,19 +111,18 @@ public function assertable(): AssertableEmailTransport
public function send(EmailMessage $message): CommunicationResult
{
$this->events->dispatch('email.send.start', [
- 'subject' => $message->headersData()->subject,
'to_count' => count($message->envelope()->to),
'cc_count' => count($message->envelope()->cc),
'bcc_count' => count($message->envelope()->bcc),
]);
- $startedAt = microtime(true);
+ $startedAt = $this->clock->monotonic();
$result = $this->transport->send($message->prepare());
$this->events->dispatch('email.send.finish', [
'successful' => $result->successful,
- 'error' => $result->error,
- 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+ 'failure_category' => $result->successful ? null : (ObservabilitySanitizer::resultContext($result)['failure_category'] ?? 'transport_error'),
+ 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000),
'transport' => is_string($result->metadata['transport'] ?? null)
? $result->metadata['transport']
: null,
@@ -116,7 +139,7 @@ public function transport(): EmailTransport
public function withDkim(DkimConfig $config): self
{
- return new self(new DkimSigningTransport($this->transport, $config), $this->events);
+ return new self(new DkimSigningTransport($this->transport, $config), $this->events, $this->clock);
}
/**
@@ -124,7 +147,7 @@ public function withDkim(DkimConfig $config): self
*/
public function withFallback(array $fallbackTransports): self
{
- return new self(new FallbackEmailTransport($this->transport, $fallbackTransports), $this->events);
+ return new self(new FallbackEmailTransport($this->transport, $fallbackTransports), $this->events, $this->clock);
}
/**
@@ -132,7 +155,7 @@ public function withFallback(array $fallbackTransports): self
*/
public function withLogging(callable $logger): self
{
- return new self(new LoggingEmailTransport($this->transport, $logger), $this->events);
+ return new self(new LoggingEmailTransport($this->transport, $logger), $this->events, $this->clock);
}
public function withPsrLogger(object $logger, string $level = 'info'): self
@@ -142,16 +165,20 @@ public function withPsrLogger(object $logger, string $level = 'info'): self
public function withRateLimit(RateLimiter $rateLimiter): self
{
- return new self(new RateLimitedEmailTransport($this->transport, $rateLimiter), $this->events);
+ return new self(new RateLimitedEmailTransport($this->transport, $rateLimiter), $this->events, $this->clock);
}
- public function withRetry(RetryPolicy $retryPolicy): self
+ public function withRetry(RetryPolicy $retryPolicy, ?CancellationSignal $cancellation = null): self
{
- return new self(new RetryEmailTransport($this->transport, $retryPolicy), $this->events);
+ return new self(
+ new RetryEmailTransport($this->transport, $retryPolicy, $cancellation),
+ $this->events,
+ $this->clock,
+ );
}
public function withTransport(EmailTransport $transport): self
{
- return new self($transport, $this->events);
+ return new self($transport, $this->events, $this->clock);
}
}
diff --git a/src/Email/Mailbox/FakeMailboxTransport.php b/src/Email/Mailbox/FakeMailboxTransport.php
index 86ae89e..ef6a533 100644
--- a/src/Email/Mailbox/FakeMailboxTransport.php
+++ b/src/Email/Mailbox/FakeMailboxTransport.php
@@ -4,11 +4,17 @@
namespace Infocyph\TalkingBytes\Email\Mailbox;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Email\Exception\MailboxProtocolException;
use Infocyph\TalkingBytes\Email\Parser\HeaderParser;
final class FakeMailboxTransport implements BodyStructureMailboxTransport, EnvelopeSummaryMailboxTransport, MailboxTransport, RawHeadersMailboxTransport, WatchableMailboxTransport
{
+ private readonly Clock $clock;
+
+ private readonly Sleeper $sleeper;
+
/**
* @var array>>
*/
@@ -24,6 +30,12 @@ final class FakeMailboxTransport implements BodyStructureMailboxTransport, Envel
*/
private array $seen = ['INBOX' => []];
+ public function __construct(?Clock $clock = null, ?Sleeper $sleeper = null)
+ {
+ $this->clock = $clock ?? Clock::system();
+ $this->sleeper = $sleeper ?? Sleeper::system();
+ }
+
public function addFlag(string $folder, int $uid, string $flag): void
{
$this->ensureMessageExists($folder, $uid);
@@ -302,11 +314,11 @@ public function watch(string $folder, callable $onEvent, int $timeoutSeconds = 3
}
$stop = $shouldStop ?? static fn(): bool => false;
- $deadline = time() + max(1, $timeoutSeconds);
+ $deadline = $this->clock->monotonic() + max(1, $timeoutSeconds);
- while (time() < $deadline && !$stop()) {
+ while ($this->clock->monotonic() < $deadline && !$stop()) {
$onEvent(sprintf('* %d EXISTS', count($this->messages[$folder])));
- usleep(250000);
+ $this->sleeper->milliseconds(250);
}
}
diff --git a/src/Email/Mailbox/ImapSocketTransport.php b/src/Email/Mailbox/ImapSocketTransport.php
index b3c370f..bb009d8 100644
--- a/src/Email/Mailbox/ImapSocketTransport.php
+++ b/src/Email/Mailbox/ImapSocketTransport.php
@@ -4,6 +4,11 @@
namespace Infocyph\TalkingBytes\Email\Mailbox;
+use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Email\Config\ImapConfig;
use Infocyph\TalkingBytes\Email\Enum\ImapSecurity;
use Infocyph\TalkingBytes\Email\Exception\MailboxAuthenticationException;
@@ -12,6 +17,12 @@
final class ImapSocketTransport implements BodyStructureMailboxTransport, EnvelopeSummaryMailboxTransport, MailboxTransport, RawHeadersMailboxTransport, RawPartMailboxTransport, WatchableMailboxTransport
{
+ private readonly Clock $clock;
+
+ private readonly EventDispatcher $events;
+
+ private readonly Sleeper $sleeper;
+
/**
* @var list
*/
@@ -29,7 +40,14 @@ final class ImapSocketTransport implements BodyStructureMailboxTransport, Envelo
public function __construct(
private readonly ImapConfig $config,
private readonly ImapResponseParser $responseParser = new ImapResponseParser(),
- ) {}
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ?Sleeper $sleeper = null,
+ ) {
+ $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ $this->clock = $clock ?? Clock::system();
+ $this->sleeper = $sleeper ?? Sleeper::system();
+ }
public function __destruct()
{
@@ -69,14 +87,20 @@ public function connect(): void
);
$this->selectedFolder = null;
- $greeting = $this->readLine();
- if (!str_starts_with(strtoupper($greeting), '* OK')) {
- throw new MailboxProtocolException(sprintf('Unexpected IMAP greeting: %s', trim($greeting)));
- }
+ try {
+ $greeting = $this->readLine();
+ if (!str_starts_with(strtoupper($greeting), '* OK')) {
+ throw new MailboxProtocolException(sprintf('Unexpected IMAP greeting: %s', trim($greeting)));
+ }
- $this->refreshCapabilities();
- $this->negotiateStartTls();
- $this->login();
+ $this->refreshCapabilities();
+ $this->negotiateStartTls();
+ $this->login();
+ } catch (\Throwable $exception) {
+ $this->closeConnection();
+
+ throw $exception;
+ }
}
public function copy(string $folder, int $uid, string $targetFolder): void
@@ -152,10 +176,7 @@ public function logout(): void
// Best effort for shutdown.
}
- fclose($this->connection);
- $this->connection = null;
- $this->capabilities = [];
- $this->selectedFolder = null;
+ $this->closeConnection();
}
public function markSeen(string $folder, int $uid): void
@@ -340,6 +361,17 @@ private function authenticateStatus(ImapResponse $response): void
throw new MailboxAuthenticationException(implode("\n", $response->lines));
}
+ private function closeConnection(): void
+ {
+ if (is_resource($this->connection)) {
+ fclose($this->connection);
+ }
+
+ $this->connection = null;
+ $this->capabilities = [];
+ $this->selectedFolder = null;
+ }
+
private function expectOk(ImapResponse $response, string $stage): ImapResponse
{
if ($response->isOk()) {
@@ -461,10 +493,10 @@ private function readTaggedResponse(string $tag): ImapResponse
$literals = [];
$status = 'NO';
$totalBytes = 0;
- $deadline = microtime(true) + $this->config->timeoutSeconds;
+ $deadline = $this->clock->monotonic() + $this->config->timeoutSeconds;
while (true) {
- if (microtime(true) >= $deadline) {
+ if ($this->clock->monotonic() >= $deadline) {
throw new MailboxConnectionException('IMAP command deadline exceeded.');
}
$line = $this->readLine();
@@ -523,7 +555,7 @@ private function runCommand(string $command): ImapResponse
$start = SocketMailboxRuntime::dispatchStart('imap', $command, [
'host' => $this->config->host,
'port' => $this->config->port,
- ]);
+ ], $this->events, $this->clock);
$imapCommand = new ImapCommand($this->nextTag(), $command);
$this->write($imapCommand->line() . "\r\n");
@@ -535,6 +567,8 @@ private function runCommand(string $command): ImapResponse
$response->status,
$start['duration_ms'],
['host' => $this->config->host, 'port' => $this->config->port],
+ $this->events,
+ $this->clock,
);
return $response;
@@ -599,10 +633,10 @@ private function watchWithIdle(callable $onEvent, int $timeoutSeconds, callable
throw new MailboxProtocolException(sprintf('IMAP IDLE was not accepted: %s', trim($continuation)));
}
- $deadline = time() + max(1, $timeoutSeconds);
+ $deadline = $this->clock->monotonic() + max(1, $timeoutSeconds);
$socket = $this->requireConnection();
- while (time() < $deadline) {
+ while ($this->clock->monotonic() < $deadline) {
if ($stop()) {
break;
}
@@ -638,9 +672,9 @@ private function watchWithIdle(callable $onEvent, int $timeoutSeconds, callable
*/
private function watchWithNoopFallback(callable $onEvent, int $timeoutSeconds, callable $stop): void
{
- $deadline = time() + max(1, $timeoutSeconds);
+ $deadline = $this->clock->monotonic() + max(1, $timeoutSeconds);
- while (time() < $deadline) {
+ while ($this->clock->monotonic() < $deadline) {
if ($stop()) {
return;
}
@@ -652,7 +686,7 @@ private function watchWithNoopFallback(callable $onEvent, int $timeoutSeconds, c
}
}
- usleep(250000);
+ $this->sleeper->milliseconds(250);
}
}
diff --git a/src/Email/Mailbox/Mailbox.php b/src/Email/Mailbox/Mailbox.php
index 7f12c56..82bdf14 100644
--- a/src/Email/Mailbox/Mailbox.php
+++ b/src/Email/Mailbox/Mailbox.php
@@ -4,6 +4,10 @@
namespace Infocyph\TalkingBytes\Email\Mailbox;
+use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Email\Config\ImapConfig;
use Infocyph\TalkingBytes\Email\Parser\EmailParser;
use Infocyph\TalkingBytes\Email\Parser\RawEmailParser;
@@ -15,9 +19,13 @@ public function __construct(
private EmailParser $parser = new RawEmailParser(),
) {}
- public static function usingImap(ImapConfig $config): self
- {
- return new self(new ImapSocketTransport($config));
+ public static function usingImap(
+ ImapConfig $config,
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ?Sleeper $sleeper = null,
+ ): self {
+ return new self(new ImapSocketTransport($config, events: $events, clock: $clock, sleeper: $sleeper));
}
public function archive(string $sourceFolder, int $uid, ?string $archiveFolder = null): void
@@ -55,6 +63,11 @@ public function archiveWithProviderStrategy(string $sourceFolder, int $uid, ?arr
$this->archive($sourceFolder, $uid, $target);
}
+ public function connect(): void
+ {
+ $this->transport->connect();
+ }
+
public function createFolder(string $name): void
{
MailboxFolderNameGuard::assertValid($name);
@@ -97,6 +110,11 @@ public function folders(): array
return $this->transport->folders();
}
+ public function logout(): void
+ {
+ $this->transport->logout();
+ }
+
public function noop(): void
{
$this->transport->noop();
@@ -142,4 +160,18 @@ public function watch(string $folder, callable $onEvent, int $timeoutSeconds = 3
MailboxFolderNameGuard::assertValid($folder);
$this->folder($folder)->watch($onEvent, $timeoutSeconds, $shouldStop);
}
+
+ public function watchUntilCancelled(
+ string $folder,
+ callable $onEvent,
+ CancellationSignal $cancellation,
+ int $timeoutSeconds = 30,
+ ): void {
+ $this->watch(
+ $folder,
+ $onEvent,
+ $timeoutSeconds,
+ $cancellation->isRequested(...),
+ );
+ }
}
diff --git a/src/Email/Mailbox/MailboxCommandRedactor.php b/src/Email/Mailbox/MailboxCommandRedactor.php
index 9340cd3..9b81bb6 100644
--- a/src/Email/Mailbox/MailboxCommandRedactor.php
+++ b/src/Email/Mailbox/MailboxCommandRedactor.php
@@ -23,10 +23,8 @@ public static function redact(string $protocol, string $command): string
private static function redactImap(string $command): string
{
- if (preg_match('/^\s*LOGIN\s+(.+?)\s+(.+)$/i', $command, $matches) === 1) {
- $username = trim($matches[1]);
-
- return sprintf('LOGIN %s [REDACTED]', $username);
+ if (preg_match('/^\s*LOGIN\s+(.+?)\s+(.+)$/i', $command) === 1) {
+ return 'LOGIN [REDACTED] [REDACTED]';
}
if (preg_match('/^\s*AUTHENTICATE\s+(.+)$/i', $command) === 1) {
@@ -42,10 +40,12 @@ private static function redactPop3(string $command): string
return 'PASS [REDACTED]';
}
- if (preg_match('/^\s*APOP\s+(.+?)\s+(.+)$/i', $command, $matches) === 1) {
- $username = trim($matches[1]);
+ if (preg_match('/^\s*USER\s+.+$/i', $command) === 1) {
+ return 'USER [REDACTED]';
+ }
- return sprintf('APOP %s [REDACTED]', $username);
+ if (preg_match('/^\s*APOP\s+(.+?)\s+(.+)$/i', $command) === 1) {
+ return 'APOP [REDACTED] [REDACTED]';
}
if (preg_match('/^\s*AUTH\s+.+$/i', $command) === 1) {
diff --git a/src/Email/Mailbox/Pop3Mailbox.php b/src/Email/Mailbox/Pop3Mailbox.php
index 2d77a43..1caf1c5 100644
--- a/src/Email/Mailbox/Pop3Mailbox.php
+++ b/src/Email/Mailbox/Pop3Mailbox.php
@@ -4,6 +4,10 @@
namespace Infocyph\TalkingBytes\Email\Mailbox;
+use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Email\Config\Pop3Config;
use Infocyph\TalkingBytes\Email\Parser\EmailParser;
use Infocyph\TalkingBytes\Email\Parser\RawEmailParser;
@@ -16,9 +20,18 @@ public function __construct(
private EmailParser $parser = new RawEmailParser(),
) {}
- public static function usingConfig(Pop3Config $config): self
+ public static function usingConfig(
+ Pop3Config $config,
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ?Sleeper $sleeper = null,
+ ): self {
+ return new self(new Pop3SocketTransport($config, $events, $clock, $sleeper));
+ }
+
+ public function connect(): void
{
- return new self(new Pop3SocketTransport($config));
+ $this->transport->connect();
}
public function delete(int $messageNumber): void
@@ -111,4 +124,24 @@ public function transport(): Pop3Transport
{
return $this->transport;
}
+
+ public function watch(
+ callable $onEvent,
+ int $timeoutSeconds = 30,
+ ?callable $shouldStop = null,
+ ): void {
+ $this->transport->watch($onEvent, $timeoutSeconds, $shouldStop);
+ }
+
+ public function watchUntilCancelled(
+ callable $onEvent,
+ CancellationSignal $cancellation,
+ int $timeoutSeconds = 30,
+ ): void {
+ $this->watch(
+ $onEvent,
+ $timeoutSeconds,
+ $cancellation->isRequested(...),
+ );
+ }
}
diff --git a/src/Email/Mailbox/Pop3SocketTransport.php b/src/Email/Mailbox/Pop3SocketTransport.php
index 2b9bd73..54281a1 100644
--- a/src/Email/Mailbox/Pop3SocketTransport.php
+++ b/src/Email/Mailbox/Pop3SocketTransport.php
@@ -4,6 +4,11 @@
namespace Infocyph\TalkingBytes\Email\Mailbox;
+use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Email\Config\Pop3Config;
use Infocyph\TalkingBytes\Email\Enum\Pop3Security;
use Infocyph\TalkingBytes\Email\Exception\MailboxAuthenticationException;
@@ -12,6 +17,12 @@
final class Pop3SocketTransport implements Pop3Transport
{
+ private readonly Clock $clock;
+
+ private readonly EventDispatcher $events;
+
+ private readonly Sleeper $sleeper;
+
/**
* @var list
*/
@@ -22,7 +33,16 @@ final class Pop3SocketTransport implements Pop3Transport
*/
private mixed $connection = null;
- public function __construct(private readonly Pop3Config $config) {}
+ public function __construct(
+ private readonly Pop3Config $config,
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ?Sleeper $sleeper = null,
+ ) {
+ $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ $this->clock = $clock ?? Clock::system();
+ $this->sleeper = $sleeper ?? Sleeper::system();
+ }
public function __destruct()
{
@@ -52,14 +72,20 @@ public function connect(): void
$this->config->security === Pop3Security::Ssl,
);
- $greeting = $this->readLine();
- if (!$this->isOkResponse($greeting)) {
- throw new MailboxProtocolException(sprintf('Unexpected POP3 greeting: %s', trim($greeting)));
- }
+ try {
+ $greeting = $this->readLine();
+ if (!$this->isOkResponse($greeting)) {
+ throw new MailboxProtocolException(sprintf('Unexpected POP3 greeting: %s', trim($greeting)));
+ }
- $this->refreshCapabilities();
- $this->negotiateStartTls();
- $this->login();
+ $this->refreshCapabilities();
+ $this->negotiateStartTls();
+ $this->login();
+ } catch (\Throwable $exception) {
+ $this->closeConnection();
+
+ throw $exception;
+ }
}
public function delete(int $messageNumber): void
@@ -211,12 +237,12 @@ public function uidl(): array
public function watch(callable $onEvent, int $timeoutSeconds = 30, ?callable $shouldStop = null): void
{
$stop = $shouldStop ?? static fn(): bool => false;
- $deadline = time() + max(1, $timeoutSeconds);
+ $deadline = $this->clock->monotonic() + max(1, $timeoutSeconds);
- while (time() < $deadline && !$stop()) {
+ while ($this->clock->monotonic() < $deadline && !$stop()) {
$status = $this->status();
$onEvent(sprintf('+OK %d messages', $status->messages));
- usleep(250000);
+ $this->sleeper->milliseconds(250);
}
}
@@ -300,10 +326,10 @@ private function readMultilineResponse(): array
{
$lines = [];
$bytes = 0;
- $deadline = microtime(true) + $this->config->timeoutSeconds;
+ $deadline = $this->clock->monotonic() + $this->config->timeoutSeconds;
while (true) {
- if (microtime(true) >= $deadline) {
+ if ($this->clock->monotonic() >= $deadline) {
throw new MailboxConnectionException('POP3 command deadline exceeded.');
}
$line = $this->readLine();
@@ -372,7 +398,7 @@ private function runSingleCommand(string $command): string
$start = SocketMailboxRuntime::dispatchStart('pop3', $command, [
'host' => $this->config->host,
'port' => $this->config->port,
- ]);
+ ], $this->events, $this->clock);
$this->write($command . "\r\n");
$response = $this->readLine();
SocketMailboxRuntime::dispatchFinish(
@@ -381,6 +407,8 @@ private function runSingleCommand(string $command): string
trim($response),
$start['duration_ms'],
['host' => $this->config->host, 'port' => $this->config->port],
+ $this->events,
+ $this->clock,
);
return $response;
diff --git a/src/Email/Mailbox/SocketMailboxRuntime.php b/src/Email/Mailbox/SocketMailboxRuntime.php
index 136ebfb..b8b938a 100644
--- a/src/Email/Mailbox/SocketMailboxRuntime.php
+++ b/src/Email/Mailbox/SocketMailboxRuntime.php
@@ -4,8 +4,10 @@
namespace Infocyph\TalkingBytes\Email\Mailbox;
-use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus;
+use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\Clock;
use Infocyph\TalkingBytes\Email\Exception\MailboxConnectionException;
+use Throwable;
final class SocketMailboxRuntime
{
@@ -60,14 +62,17 @@ public static function dispatchFinish(
string $status,
int $startedAtMs,
array $endpoint,
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
): void {
- CommunicationEventBus::dispatch('mailbox.command.finish', [
+ $runtimeClock = $clock ?? Clock::system();
+ self::dispatch($events, 'mailbox.command.finish', [
'protocol' => $protocol,
'host' => $endpoint['host'],
'port' => $endpoint['port'],
'command' => $command,
'status' => $status,
- 'duration_ms' => (int) round((microtime(true) * 1000) - $startedAtMs),
+ 'duration_ms' => (int) round(($runtimeClock->monotonic() * 1000) - $startedAtMs),
]);
}
@@ -75,10 +80,16 @@ public static function dispatchFinish(
* @param array{host:string,port:int} $endpoint
* @return array{command:string,duration_ms:int}
*/
- public static function dispatchStart(string $protocol, string $command, array $endpoint): array
- {
+ public static function dispatchStart(
+ string $protocol,
+ string $command,
+ array $endpoint,
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ): array {
$redactedCommand = MailboxCommandRedactor::redact($protocol, $command);
- CommunicationEventBus::dispatch('mailbox.command.start', [
+ $runtimeClock = $clock ?? Clock::system();
+ self::dispatch($events, 'mailbox.command.start', [
'protocol' => $protocol,
'host' => $endpoint['host'],
'port' => $endpoint['port'],
@@ -87,7 +98,7 @@ public static function dispatchStart(string $protocol, string $command, array $e
return [
'command' => $redactedCommand,
- 'duration_ms' => (int) round(microtime(true) * 1000),
+ 'duration_ms' => (int) round($runtimeClock->monotonic() * 1000),
];
}
@@ -180,4 +191,18 @@ public static function write(mixed $connection, string $value, string $protocol)
$remaining = substr($remaining, $written);
}
}
+
+ /** @param array $payload */
+ private static function dispatch(?EventDispatcher $events, string $event, array $payload): void
+ {
+ if ($events === null) {
+ return;
+ }
+
+ try {
+ $events->dispatch($event, $payload);
+ } catch (Throwable) {
+ // Observability must never affect mailbox protocol outcomes.
+ }
+ }
}
diff --git a/src/Email/Parser/BounceParser.php b/src/Email/Parser/BounceParser.php
index 70972be..4577513 100644
--- a/src/Email/Parser/BounceParser.php
+++ b/src/Email/Parser/BounceParser.php
@@ -4,14 +4,23 @@
namespace Infocyph\TalkingBytes\Email\Parser;
-use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus;
+use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
use Infocyph\TalkingBytes\Email\Enum\BounceType;
use Infocyph\TalkingBytes\Email\ValueObject\BounceReport;
use Infocyph\TalkingBytes\Email\ValueObject\ParsedEmail;
final readonly class BounceParser
{
- public function __construct(private DeliveryStatusParser $deliveryStatusParser = new DeliveryStatusParser()) {}
+ private EventDispatcher $events;
+
+ public function __construct(
+ private DeliveryStatusParser $deliveryStatusParser = new DeliveryStatusParser(),
+ ?EventDispatcher $events = null,
+ ) {
+ $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ }
public function parse(ParsedEmail $email): ?BounceReport
{
@@ -190,7 +199,7 @@ private function deliveryStatusFromParts(ParsedEmail $email): array
private function dispatchDetectedEvent(BounceReport $report): void
{
- CommunicationEventBus::dispatch('bounce.detected', [
+ $this->events->dispatch('bounce.detected', [
'type' => $report->type->value,
'recipient' => $report->recipient,
'status' => $report->status,
diff --git a/src/Email/Parser/CharsetDecoder.php b/src/Email/Parser/CharsetDecoder.php
index a287975..155ec70 100644
--- a/src/Email/Parser/CharsetDecoder.php
+++ b/src/Email/Parser/CharsetDecoder.php
@@ -68,16 +68,12 @@ private function convertWithIconv(string $value, string $charset): ?string
return null;
}
- $previous = set_error_handler(static fn(): bool => true);
+ set_error_handler(static fn(): bool => true);
try {
$converted = iconv($charset, 'UTF-8//IGNORE', $value);
} finally {
- if ($previous !== null) {
- set_error_handler($previous);
- } else {
- restore_error_handler();
- }
+ restore_error_handler();
}
return is_string($converted) ? $converted : null;
diff --git a/src/Email/Receiver/SpoolEmailReceiver.php b/src/Email/Receiver/SpoolEmailReceiver.php
index 160c15e..46f5e8a 100644
--- a/src/Email/Receiver/SpoolEmailReceiver.php
+++ b/src/Email/Receiver/SpoolEmailReceiver.php
@@ -5,7 +5,10 @@
namespace Infocyph\TalkingBytes\Email\Receiver;
use FilesystemIterator;
-use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus;
+use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
+use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\Clock;
use Infocyph\TalkingBytes\Email\Config\SpoolConfig;
use Infocyph\TalkingBytes\Email\Parser\EmailParser;
use Infocyph\TalkingBytes\Email\Parser\RawEmailParser;
@@ -15,13 +18,21 @@
final readonly class SpoolEmailReceiver implements EmailReceiver
{
+ private Clock $clock;
+
+ private EventDispatcher $events;
+
public function __construct(
private SpoolConfig $config,
private EmailParser $parser = new RawEmailParser(),
private bool $deleteAfterRead = false,
private ?string $moveAfterRead = null,
private ?string $failedDirectory = null,
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
) {
+ $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ $this->clock = $clock ?? Clock::system();
$directories = array_filter([
$this->config->directory,
$this->config->processingDirectory,
@@ -96,7 +107,7 @@ private function buildMetadata(string $originalPath, string $processingPath, boo
'path' => $originalPath,
'original_path' => $originalPath,
'processing_path' => $processingPath,
- 'consumed_at' => $consume ? gmdate(DATE_ATOM) : null,
+ 'consumed_at' => $consume ? gmdate(DATE_ATOM, (int) floor($this->clock->timestamp())) : null,
'size_bytes' => filesize($processingPath) ?: 0,
];
}
@@ -140,7 +151,7 @@ private function firstSpoolFile(): ?string
}
$extension = ltrim($this->config->extension, '.');
- $now = time();
+ $now = (int) floor($this->clock->timestamp());
$candidate = null;
foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $entry) {
if (!$entry instanceof SplFileInfo) {
@@ -255,10 +266,9 @@ private function readNext(bool $consume): ?ParsedEmail
}
$processingFile = $sourceFile;
- $startedAt = microtime(true);
- CommunicationEventBus::dispatch('email.receive.start', [
+ $startedAt = $this->clock->monotonic();
+ $this->events->dispatch('email.receive.start', [
'source' => 'spool',
- 'path' => $sourceFile,
'consume' => $consume,
]);
@@ -267,12 +277,11 @@ private function readNext(bool $consume): ?ParsedEmail
$raw = $this->readFile($processingFile, $consume);
if ($raw === false) {
$this->markFailed($processingFile, 'Unable to read spool file.');
- CommunicationEventBus::dispatch('email.receive.finish', [
+ $this->events->dispatch('email.receive.finish', [
'source' => 'spool',
'successful' => false,
- 'path' => $processingFile,
- 'error' => 'Unable to read spool file.',
- 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+ 'failure_category' => 'read_failure',
+ 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000),
]);
return null;
@@ -281,17 +290,17 @@ private function readNext(bool $consume): ?ParsedEmail
$parsed = $this->parseFile($raw, $this->buildMetadata($sourceFile, $processingFile, $consume));
} catch (\Throwable $exception) {
$this->markFailed($processingFile, $exception->getMessage());
- CommunicationEventBus::dispatch('email.parse.failed', [
+ $this->events->dispatch('email.parse.failed', [
'source' => 'spool',
- 'path' => $processingFile,
- 'error' => $exception->getMessage(),
+ 'failure_category' => 'parse_failure',
+ 'exception_class' => $exception::class,
]);
- CommunicationEventBus::dispatch('email.receive.finish', [
+ $this->events->dispatch('email.receive.finish', [
'source' => 'spool',
'successful' => false,
- 'path' => $processingFile,
- 'error' => $exception->getMessage(),
- 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+ 'failure_category' => 'parse_failure',
+ 'exception_class' => $exception::class,
+ 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000),
]);
return null;
@@ -301,12 +310,10 @@ private function readNext(bool $consume): ?ParsedEmail
$this->finalizeRead($processingFile);
}
- CommunicationEventBus::dispatch('email.receive.finish', [
+ $this->events->dispatch('email.receive.finish', [
'source' => 'spool',
'successful' => true,
- 'path' => $processingFile,
- 'subject' => $parsed->subject,
- 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+ 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000),
]);
return $parsed;
diff --git a/src/Email/System/SendmailProcess.php b/src/Email/System/SendmailProcess.php
new file mode 100644
index 0000000..a867391
--- /dev/null
+++ b/src/Email/System/SendmailProcess.php
@@ -0,0 +1,348 @@
+ $pipes
+ */
+ private function __construct(
+ mixed $process,
+ private array $pipes,
+ private readonly float $deadline,
+ private readonly int $timeoutSeconds,
+ private readonly Clock $clock,
+ private readonly Sleeper $sleeper,
+ private readonly ?CancellationSignal $cancellation,
+ private readonly ?int $processGroupId,
+ ) {
+ $this->process = $process;
+ }
+
+ public function __destruct()
+ {
+ $this->close();
+ }
+
+ /**
+ * @param list $command
+ */
+ public static function start(
+ array $command,
+ int $timeoutSeconds,
+ ?CancellationSignal $cancellation = null,
+ ?Clock $clock = null,
+ ?Sleeper $sleeper = null,
+ ): self {
+ $runtimeClock = $clock ?? Clock::system();
+ $runtimeSleeper = $sleeper ?? Sleeper::system();
+
+ if ($cancellation?->isRequested() === true) {
+ throw new RuntimeException('Sendmail process cancelled.');
+ }
+
+ $process = proc_open($command, self::descriptorSpec(), $pipes);
+ if (!is_resource($process)) {
+ throw new RuntimeException('Unable to open sendmail process.');
+ }
+
+ foreach ($pipes as $pipe) {
+ if (!is_resource($pipe) || stream_set_blocking($pipe, false)) {
+ continue;
+ }
+
+ self::closePipeSet($pipes);
+ proc_terminate($process);
+ proc_close($process);
+
+ throw new RuntimeException('Unable to configure sendmail process pipes.');
+ }
+
+ return new self(
+ $process,
+ $pipes,
+ $runtimeClock->monotonic() + $timeoutSeconds,
+ $timeoutSeconds,
+ $runtimeClock,
+ $runtimeSleeper,
+ $cancellation,
+ self::tryCreateProcessGroup($process),
+ );
+ }
+
+ public function close(): void
+ {
+ self::closePipeSet($this->pipes);
+ $this->pipes = [];
+
+ if (!is_resource($this->process)) {
+ return;
+ }
+
+ $process = $this->process;
+ $status = proc_get_status($process);
+ if ($status['running']) {
+ $this->terminate();
+ }
+
+ proc_close($process);
+ $this->process = null;
+ }
+
+ public function finish(): SendmailProcessResult
+ {
+ $this->closeStdin();
+
+ while (true) {
+ $this->assertActive();
+ $this->drainOutput();
+
+ $status = proc_get_status($this->requireProcess());
+ if (!$status['running']) {
+ $this->drainOutput();
+
+ $exitCode = $status['exitcode'];
+ $closeCode = proc_close($this->requireProcess());
+ $this->process = null;
+ self::closePipeSet($this->pipes);
+ $this->pipes = [];
+
+ if ($closeCode >= 0) {
+ $exitCode = $closeCode;
+ }
+
+ return new SendmailProcessResult($exitCode, $this->stdout, $this->stderr);
+ }
+
+ $this->sleeper->milliseconds(self::POLL_DELAY_MS);
+ }
+ }
+
+ public function write(string $chunk): void
+ {
+ if ($chunk === '') {
+ return;
+ }
+
+ $stdin = $this->pipe(0);
+ $length = strlen($chunk);
+ $written = 0;
+
+ while ($written < $length) {
+ $this->assertActive();
+ $this->drainOutput();
+
+ $current = fwrite($stdin, substr($chunk, $written));
+ if ($current === false) {
+ throw new RuntimeException('Unable to write email payload to sendmail process.');
+ }
+
+ if ($current === 0) {
+ $status = proc_get_status($this->requireProcess());
+ if (!$status['running']) {
+ throw new RuntimeException('Sendmail process exited while receiving the email payload.');
+ }
+
+ $this->sleeper->milliseconds(self::POLL_DELAY_MS);
+
+ continue;
+ }
+
+ $written += $current;
+ }
+ }
+
+ /**
+ * @param array $pipes
+ */
+ private static function closePipeSet(array $pipes): void
+ {
+ foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) {
+ fclose($pipe);
+ }
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private static function descriptorSpec(): array
+ {
+ return [
+ 0 => ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+ }
+
+ /**
+ * @param resource $process
+ */
+ private static function tryCreateProcessGroup(mixed $process): ?int
+ {
+ if (
+ !function_exists('posix_setpgid')
+ || !function_exists('posix_getpgid')
+ || !function_exists('posix_kill')
+ ) {
+ return null;
+ }
+
+ $status = proc_get_status($process);
+ $pid = $status['pid'];
+ if ($pid < 1) {
+ return null;
+ }
+
+ try {
+ if (!posix_setpgid($pid, $pid)) {
+ return null;
+ }
+
+ return posix_getpgid($pid) === $pid ? $pid : null;
+ } catch (Throwable) {
+ return null;
+ }
+ }
+
+ private function appendDiagnostic(string $buffer, string|false $chunk): string
+ {
+ if (!is_string($chunk) || $chunk === '' || strlen($buffer) >= self::MAX_DIAGNOSTIC_BYTES) {
+ return $buffer;
+ }
+
+ return $buffer . substr($chunk, 0, self::MAX_DIAGNOSTIC_BYTES - strlen($buffer));
+ }
+
+ private function assertActive(): void
+ {
+ if ($this->cancellation?->isRequested() === true) {
+ $this->terminate();
+
+ throw new RuntimeException('Sendmail process cancelled.');
+ }
+
+ if ($this->clock->monotonic() >= $this->deadline) {
+ $this->terminate();
+
+ throw new RuntimeException(sprintf(
+ 'Sendmail process timed out after %d seconds.',
+ $this->timeoutSeconds,
+ ));
+ }
+ }
+
+ private function closeStdin(): void
+ {
+ $stdin = $this->pipes[0] ?? null;
+ if (is_resource($stdin)) {
+ fclose($stdin);
+ }
+
+ $this->pipes[0] = null;
+ }
+
+ private function drainOutput(): void
+ {
+ $stdout = $this->pipes[1] ?? null;
+ if (is_resource($stdout)) {
+ $this->stdout = $this->appendDiagnostic($this->stdout, stream_get_contents($stdout));
+ }
+
+ $stderr = $this->pipes[2] ?? null;
+ if (is_resource($stderr)) {
+ $this->stderr = $this->appendDiagnostic($this->stderr, stream_get_contents($stderr));
+ }
+ }
+
+ /**
+ * @return resource
+ */
+ private function pipe(int $index): mixed
+ {
+ $pipe = $this->pipes[$index] ?? null;
+ if (!is_resource($pipe)) {
+ throw new RuntimeException('Sendmail process pipe is not available.');
+ }
+
+ return $pipe;
+ }
+
+ /**
+ * @return resource
+ */
+ private function requireProcess(): mixed
+ {
+ if (!is_resource($this->process)) {
+ throw new RuntimeException('Sendmail process is not available.');
+ }
+
+ return $this->process;
+ }
+
+ private function signal(int $signal): void
+ {
+ if ($this->processGroupId !== null && function_exists('posix_kill')) {
+ try {
+ if (posix_kill(-$this->processGroupId, $signal)) {
+ return;
+ }
+ } catch (Throwable) {
+ // Fall through to portable direct-child termination.
+ }
+ }
+
+ if (is_resource($this->process)) {
+ proc_terminate($this->process, $signal);
+ }
+ }
+
+ private function terminate(): void
+ {
+ if (!is_resource($this->process)) {
+ return;
+ }
+
+ $process = $this->process;
+ $status = proc_get_status($process);
+ if (!$status['running']) {
+ return;
+ }
+
+ $this->signal(self::GRACEFUL_SIGNAL);
+ $this->sleeper->milliseconds(self::TERMINATION_GRACE_MS);
+
+ $status = proc_get_status($process);
+ if ($status['running']) {
+ $this->signal(self::FORCE_SIGNAL);
+ }
+ }
+}
diff --git a/src/Email/System/SendmailProcessResult.php b/src/Email/System/SendmailProcessResult.php
new file mode 100644
index 0000000..bbf969a
--- /dev/null
+++ b/src/Email/System/SendmailProcessResult.php
@@ -0,0 +1,14 @@
+ count($message->envelope()->to),
'cc_count' => count($message->envelope()->cc),
'bcc_count' => count($message->envelope()->bcc),
- 'subject' => $message->headersData()->subject,
]);
try {
@@ -32,18 +32,13 @@ public function send(EmailMessage $message): CommunicationResult
} catch (Throwable $throwable) {
($this->logger)('email.send.finish', [
'successful' => false,
- 'error' => $throwable->getMessage(),
- 'metadata' => [],
+ ...ObservabilitySanitizer::throwableContext($throwable),
]);
throw $throwable;
}
- ($this->logger)('email.send.finish', [
- 'successful' => $result->successful,
- 'error' => $result->error,
- 'metadata' => $result->metadata,
- ]);
+ ($this->logger)('email.send.finish', ObservabilitySanitizer::resultContext($result));
return $result;
}
diff --git a/src/Email/Transport/RetryEmailTransport.php b/src/Email/Transport/RetryEmailTransport.php
index b902fa5..cbfc80f 100644
--- a/src/Email/Transport/RetryEmailTransport.php
+++ b/src/Email/Transport/RetryEmailTransport.php
@@ -5,6 +5,7 @@
namespace Infocyph\TalkingBytes\Email\Transport;
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
use Infocyph\TalkingBytes\Core\Support\RetryExecutor;
use Infocyph\TalkingBytes\Email\EmailMessage;
use Infocyph\TalkingBytes\Retry\RetryPolicy;
@@ -14,10 +15,15 @@
public function __construct(
private EmailTransport $innerTransport,
private RetryPolicy $retryPolicy,
+ private ?CancellationSignal $cancellation = null,
) {}
public function send(EmailMessage $message): CommunicationResult
{
- return RetryExecutor::run($this->retryPolicy, fn(): CommunicationResult => $this->innerTransport->send($message));
+ return RetryExecutor::run(
+ $this->retryPolicy,
+ fn(): CommunicationResult => $this->innerTransport->send($message),
+ cancellation: $this->cancellation,
+ );
}
}
diff --git a/src/Email/Transport/SendmailTransport.php b/src/Email/Transport/SendmailTransport.php
index 87ff507..8dbca4e 100644
--- a/src/Email/Transport/SendmailTransport.php
+++ b/src/Email/Transport/SendmailTransport.php
@@ -5,21 +5,33 @@
namespace Infocyph\TalkingBytes\Email\Transport;
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Email\Config\SendmailConfig;
use Infocyph\TalkingBytes\Email\EmailMessage;
use Infocyph\TalkingBytes\Email\System\EmailHeaderBuilder;
use Infocyph\TalkingBytes\Email\System\RawEmailBuilder;
+use Infocyph\TalkingBytes\Email\System\SendmailProcess;
use RuntimeException;
final readonly class SendmailTransport implements EmailTransport
{
- private const int MAX_DIAGNOSTIC_BYTES = 65_536;
+ private Clock $clock;
+
+ private Sleeper $sleeper;
public function __construct(
private SendmailConfig $config = new SendmailConfig(),
private RawEmailBuilder $rawEmailBuilder = new RawEmailBuilder(),
private EmailHeaderBuilder $headerBuilder = new EmailHeaderBuilder(),
- ) {}
+ private ?CancellationSignal $cancellation = null,
+ ?Clock $clock = null,
+ ?Sleeper $sleeper = null,
+ ) {
+ $this->clock = $clock ?? Clock::system();
+ $this->sleeper = $sleeper ?? Sleeper::system();
+ }
public function send(EmailMessage $message): CommunicationResult
{
@@ -46,15 +58,6 @@ public function send(EmailMessage $message): CommunicationResult
return EmailTransportResultFactory::success('sendmail', $messageId, $recipients, ['size_bytes' => $sizeBytes]);
}
- private function appendDiagnostic(string $buffer, string|false $chunk): string
- {
- if (!is_string($chunk) || $chunk === '' || strlen($buffer) >= self::MAX_DIAGNOSTIC_BYTES) {
- return $buffer;
- }
-
- return $buffer . substr($chunk, 0, self::MAX_DIAGNOSTIC_BYTES - strlen($buffer));
- }
-
/**
* @return list
*/
@@ -70,165 +73,38 @@ private function buildCommand(EmailMessage $message): array
return $command;
}
- /**
- * @param array $pipes
- */
- private function closePipes(array $pipes): void
- {
- foreach ($pipes as $pipe) {
- if (!is_resource($pipe)) {
- continue;
- }
-
- fclose($pipe);
- }
- }
-
- /**
- * @param resource $process
- */
- private function closeProcess($process, bool $alreadyClosed): void
- {
- if ($alreadyClosed || !is_resource($process)) {
- return;
- }
-
- proc_terminate($process);
- proc_close($process);
- }
-
- /**
- * @return array
- */
- private function descriptorSpec(): array
- {
- return [
- 0 => ['pipe', 'r'],
- 1 => ['pipe', 'w'],
- 2 => ['pipe', 'w'],
- ];
- }
-
private function executeSendmail(EmailMessage $message): int
{
- $process = proc_open($this->buildCommand($message), $this->descriptorSpec(), $pipes);
-
- if (!is_resource($process)) {
- throw new RuntimeException('Unable to open sendmail process.');
- }
-
- $processClosed = false;
- $sizeBytes = 0;
+ $process = SendmailProcess::start(
+ $this->buildCommand($message),
+ $this->config->timeoutSeconds,
+ $this->cancellation,
+ $this->clock,
+ $this->sleeper,
+ );
try {
- $sizeBytes = $this->writeToStdin($pipes[0], $message);
- fclose($pipes[0]);
-
- stream_set_blocking($pipes[1], false);
- stream_set_blocking($pipes[2], false);
-
- [$exitCode, $stdout, $stderr] = $this->readProcessOutputUntilExit(
- $process,
- $pipes[1],
- $pipes[2],
- $this->config->timeoutSeconds,
+ $sizeBytes = $this->rawEmailBuilder->buildToStream(
+ $message,
+ $process->write(...),
+ includeSubject: true,
+ maxBytes: $this->config->maxMessageBytes,
);
- $processClosed = true;
+ $result = $process->finish();
} finally {
- $this->closePipes($pipes);
- $this->closeProcess($process, $processClosed);
+ $process->close();
}
- if ($exitCode !== 0) {
- $detail = trim(($stderr ?: $stdout) ?: 'unknown error');
+ if ($result->exitCode !== 0) {
+ $detail = trim(($result->stderr ?: $result->stdout) ?: 'unknown error');
- throw new RuntimeException(sprintf('Sendmail exited with code %d: %s', $exitCode, $detail));
+ throw new RuntimeException(sprintf(
+ 'Sendmail exited with code %d: %s',
+ $result->exitCode,
+ $detail,
+ ));
}
return $sizeBytes;
}
-
- /**
- * @param resource $process
- * @param resource $stdout
- * @param resource $stderr
- * @return array{0:int,1:string,2:string}
- */
- private function readProcessOutputUntilExit($process, $stdout, $stderr, int $timeoutSeconds): array
- {
- $start = microtime(true);
- $stdoutBuffer = '';
- $stderrBuffer = '';
- $terminated = false;
-
- while (true) {
- $status = proc_get_status($process);
- $stdoutBuffer = $this->appendDiagnostic($stdoutBuffer, stream_get_contents($stdout));
- $stderrBuffer = $this->appendDiagnostic($stderrBuffer, stream_get_contents($stderr));
-
- if (!$status['running']) {
- break;
- }
-
- if ((microtime(true) - $start) > $timeoutSeconds) {
- $terminated = true;
- proc_terminate($process);
- usleep(100000);
-
- $statusAfterGrace = proc_get_status($process);
- if ($statusAfterGrace['running']) {
- proc_terminate($process, 9);
- }
-
- break;
- }
-
- usleep(10000);
- }
-
- $stdoutBuffer = $this->appendDiagnostic($stdoutBuffer, stream_get_contents($stdout));
- $stderrBuffer = $this->appendDiagnostic($stderrBuffer, stream_get_contents($stderr));
-
- $exitCode = proc_close($process);
-
- if ($terminated) {
- throw new RuntimeException(sprintf('Sendmail process timed out after %d seconds.', $timeoutSeconds));
- }
-
- return [$exitCode, $stdoutBuffer, $stderrBuffer];
- }
-
- /**
- * @param resource $stdin
- */
- private function writeChunk($stdin, string $chunk): void
- {
- $length = strlen($chunk);
- $written = 0;
-
- while ($written < $length) {
- $current = fwrite($stdin, substr($chunk, $written));
-
- if ($current === false || $current === 0) {
- throw new RuntimeException('Unable to write email payload to sendmail process.');
- }
-
- $written += $current;
- }
- }
-
- /**
- * @param resource $stdin
- */
- private function writeToStdin($stdin, EmailMessage $message): int
- {
- return $this->rawEmailBuilder->buildToStream(
- $message,
- function (string $chunk) use ($stdin): void {
- $this->writeChunk($stdin, $chunk);
- },
- includeSubject: true,
- maxBytes: $this->config->maxMessageBytes,
- );
- }
}
diff --git a/src/Email/Transport/SmtpTransport.php b/src/Email/Transport/SmtpTransport.php
index 7708496..8bca8f3 100644
--- a/src/Email/Transport/SmtpTransport.php
+++ b/src/Email/Transport/SmtpTransport.php
@@ -5,6 +5,7 @@
namespace Infocyph\TalkingBytes\Email\Transport;
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\Clock;
use Infocyph\TalkingBytes\Email\Config\SmtpConfig;
use Infocyph\TalkingBytes\Email\EmailMessage;
use Infocyph\TalkingBytes\Email\Enum\SmtpAuthMechanism;
@@ -22,13 +23,18 @@
final readonly class SmtpTransport implements EmailTransport
{
+ private Clock $clock;
+
public function __construct(
private SmtpConfig $config,
private RawEmailBuilder $rawEmailBuilder = new RawEmailBuilder(),
private SmtpCapabilityParser $capabilityParser = new SmtpCapabilityParser(),
private ?SmtpEnvelopePlanner $envelopePlanner = null,
private SmtpTlsContext $tlsContext = new SmtpTlsContext(),
- ) {}
+ ?Clock $clock = null,
+ ) {
+ $this->clock = $clock ?? Clock::system();
+ }
public function send(EmailMessage $message): CommunicationResult
{
@@ -37,7 +43,7 @@ public function send(EmailMessage $message): CommunicationResult
$connection = null;
$messageStream = null;
$capabilities = new SmtpCapabilities();
- $start = microtime(true);
+ $start = $this->clock->monotonic();
$serverGreeting = null;
$authMechanism = null;
$sessionStarted = false;
@@ -194,7 +200,7 @@ private function buildRuntimeMetadata(
'smtp_port' => $this->config->port,
'security' => $this->config->security->value,
'ehlo_capabilities' => array_keys($capabilities->values),
- 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+ 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000),
'message_id' => $messageId,
'auth_mechanism' => $authMechanism,
'server_greeting' => $serverGreeting !== null ? trim($serverGreeting) : null,
@@ -339,10 +345,10 @@ private function readResponse($connection, array &$transcript = []): array
$response = '';
$lines = [];
$code = 0;
- $deadline = microtime(true) + $this->config->timeoutSeconds;
+ $deadline = $this->clock->monotonic() + $this->config->timeoutSeconds;
while (true) {
- if (microtime(true) >= $deadline) {
+ if ($this->clock->monotonic() >= $deadline) {
throw new RuntimeException('SMTP command deadline exceeded.');
}
diff --git a/src/Grpc/GrpcClient.php b/src/Grpc/GrpcClient.php
index 8c41ad5..250be53 100644
--- a/src/Grpc/GrpcClient.php
+++ b/src/Grpc/GrpcClient.php
@@ -8,8 +8,12 @@
use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer;
use Infocyph\TalkingBytes\Grpc\Contract\GrpcMiddleware;
use Infocyph\TalkingBytes\Grpc\Middleware\RetryMiddleware;
+use Infocyph\TalkingBytes\Grpc\Native\GeneratedStubGrpcInvoker;
use Infocyph\TalkingBytes\Grpc\Native\NativeGrpcInvoker;
use Infocyph\TalkingBytes\Grpc\Native\NativeGrpcResult;
use Infocyph\TalkingBytes\Grpc\Native\NativeGrpcStreamingInvoker;
@@ -24,6 +28,8 @@
final readonly class GrpcClient
{
+ private Clock $clock;
+
private EventDispatcher $events;
private GrpcPipeline $pipeline;
@@ -36,21 +42,41 @@ private function __construct(
private array $middlewares = [],
private ?NativeGrpcStreamingInvoker $streamingInvoker = null,
?EventDispatcher $events = null,
+ ?Clock $clock = null,
) {
$this->pipeline = new GrpcPipeline($transport, $middlewares);
$this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ $this->clock = $clock ?? Clock::system();
}
/**
* @param callable(GrpcRequest): GrpcResponse $caller
*/
- public static function using(callable $caller, ?EventDispatcher $events = null): self
+ public static function using(callable $caller, ?EventDispatcher $events = null, ?Clock $clock = null): self
{
- return new self(new GrpcTransport($caller, $events), events: $events);
+ return new self(new GrpcTransport($caller, $events, $clock), events: $events, clock: $clock);
}
- public static function usingNative(NativeGrpcInvoker $invoker): self
- {
+ /**
+ * @param array $methodMap
+ */
+ public static function usingGeneratedStub(
+ object $stubClient,
+ array $methodMap = [],
+ ?EventDispatcher $events = null,
+ ?CancellationSignal $cancellation = null,
+ ?Clock $clock = null,
+ ): self {
+ $invoker = new GeneratedStubGrpcInvoker($stubClient, $methodMap, $cancellation);
+
+ return self::usingNativeStreaming($invoker, $invoker, $events, $clock);
+ }
+
+ public static function usingNative(
+ NativeGrpcInvoker $invoker,
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ): self {
return self::using(
static function (GrpcRequest $request) use ($invoker): GrpcResponse {
$native = $invoker->invoke(
@@ -68,14 +94,23 @@ static function (GrpcRequest $request) use ($invoker): GrpcResponse {
metadata: $native->metadata,
);
},
+ $events,
+ $clock,
);
}
public static function usingNativeStreaming(
NativeGrpcInvoker $invoker,
NativeGrpcStreamingInvoker $streamingInvoker,
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
): self {
- return new self(self::usingNative($invoker)->transport, streamingInvoker: $streamingInvoker);
+ return new self(
+ self::usingNative($invoker, $events, $clock)->transport,
+ streamingInvoker: $streamingInvoker,
+ events: $events,
+ clock: $clock,
+ );
}
/**
@@ -135,9 +170,11 @@ public function supportsStreaming(): bool
return $this->streamingInvoker !== null;
}
- public function withGrpcRetry(?GrpcRetryPolicy $policy = null): self
- {
- return $this->withRetryPolicy($policy ?? GrpcRetryPolicy::standard());
+ public function withGrpcRetry(
+ ?GrpcRetryPolicy $policy = null,
+ ?CancellationSignal $cancellation = null,
+ ): self {
+ return $this->withRetryPolicy($policy ?? GrpcRetryPolicy::standard(), $cancellation);
}
public function withMiddleware(GrpcMiddleware $middleware): self
@@ -145,7 +182,7 @@ public function withMiddleware(GrpcMiddleware $middleware): self
$middlewares = $this->middlewares;
$middlewares[] = $middleware;
- return new self($this->transport, $middlewares, $this->streamingInvoker, $this->events);
+ return new self($this->transport, $middlewares, $this->streamingInvoker, $this->events, $this->clock);
}
/**
@@ -153,12 +190,12 @@ public function withMiddleware(GrpcMiddleware $middleware): self
*/
public function withMiddlewares(array $middlewares): self
{
- return new self($this->transport, $middlewares, $this->streamingInvoker, $this->events);
+ return new self($this->transport, $middlewares, $this->streamingInvoker, $this->events, $this->clock);
}
- public function withRetryPolicy(RetryPolicy $policy): self
+ public function withRetryPolicy(RetryPolicy $policy, ?CancellationSignal $cancellation = null): self
{
- return $this->withMiddleware(new RetryMiddleware($policy));
+ return $this->withMiddleware(new RetryMiddleware($policy, $cancellation));
}
/**
@@ -210,7 +247,7 @@ private function runStream(string $streamType, string $method, callable $execute
);
}
- $startedAt = microtime(true);
+ $startedAt = $this->clock->monotonic();
$this->events->dispatch('grpc.stream.start', [
'transport' => 'grpc',
'type' => $streamType,
@@ -220,13 +257,13 @@ private function runStream(string $streamType, string $method, callable $execute
try {
$native = $execute($this->streamingInvoker);
} catch (Throwable $exception) {
- $durationMs = (int) ((microtime(true) - $startedAt) * 1000);
+ $durationMs = (int) (($this->clock->monotonic() - $startedAt) * 1000);
$this->events->dispatch('grpc.stream.failed', [
'transport' => 'grpc',
'type' => $streamType,
'method' => $method,
'duration_ms' => $durationMs,
- 'error' => $exception->getMessage(),
+ ...ObservabilitySanitizer::throwableContext($exception),
]);
$error = new GrpcCallError(
@@ -254,7 +291,7 @@ private function runStream(string $streamType, string $method, callable $execute
);
}
- $durationMs = (int) ((microtime(true) - $startedAt) * 1000);
+ $durationMs = (int) (($this->clock->monotonic() - $startedAt) * 1000);
$response = new GrpcResponse(
status: GrpcStatus::fromCode($native->statusCode),
message: $native->message,
diff --git a/src/Grpc/GrpcClientFactory.php b/src/Grpc/GrpcClientFactory.php
new file mode 100644
index 0000000..9bc8266
--- /dev/null
+++ b/src/Grpc/GrpcClientFactory.php
@@ -0,0 +1,191 @@
+ $config
+ */
+ public function using(callable $caller, array $config = []): GrpcClient
+ {
+ return $this->applyResolvedConfig(
+ GrpcClient::using($caller, $this->events, $this->clock),
+ $config,
+ );
+ }
+
+ /**
+ * @param array $methodMap
+ * @param array $config
+ */
+ public function usingGeneratedStub(
+ object $stubClient,
+ array $methodMap = [],
+ array $config = [],
+ ): GrpcClient {
+ return $this->applyResolvedConfig(
+ GrpcClient::usingGeneratedStub(
+ $stubClient,
+ $methodMap,
+ $this->events,
+ $this->cancellation,
+ $this->clock,
+ ),
+ $config,
+ );
+ }
+
+ /**
+ * @param array $config
+ */
+ public function usingNative(
+ NativeGrpcInvoker $invoker,
+ ?NativeGrpcStreamingInvoker $streamingInvoker = null,
+ array $config = [],
+ ): GrpcClient {
+ $client = $streamingInvoker instanceof NativeGrpcStreamingInvoker
+ ? GrpcClient::usingNativeStreaming($invoker, $streamingInvoker, $this->events, $this->clock)
+ : GrpcClient::usingNative($invoker, $this->events, $this->clock);
+
+ return $this->applyResolvedConfig($client, $config);
+ }
+
+ /** @param array $config */
+ private static function bool(array $config, string $key, bool $default): bool
+ {
+ if (!array_key_exists($key, $config)) {
+ return $default;
+ }
+
+ $value = $config[$key];
+ if (is_bool($value)) {
+ return $value;
+ }
+
+ if (is_int($value) && ($value === 0 || $value === 1)) {
+ return $value === 1;
+ }
+
+ if (is_string($value)) {
+ $parsed = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
+ if (is_bool($parsed)) {
+ return $parsed;
+ }
+ }
+
+ throw new InvalidArgumentException(sprintf('gRPC resolved configuration key "%s" must be a boolean.', $key));
+ }
+
+ /** @param array $config */
+ private static function float(array $config, string $key, float $default): float
+ {
+ $value = $config[$key] ?? $default;
+ if (is_float($value) || is_int($value) || (is_string($value) && is_numeric($value))) {
+ return (float) $value;
+ }
+
+ throw new InvalidArgumentException(sprintf('gRPC resolved configuration key "%s" must be numeric.', $key));
+ }
+
+ /** @param array $config */
+ private static function int(array $config, string $key, int $default): int
+ {
+ $value = $config[$key] ?? $default;
+ if (is_int($value)) {
+ return $value;
+ }
+
+ if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) {
+ $parsed = filter_var($value, FILTER_VALIDATE_INT);
+ if (is_int($parsed)) {
+ return $parsed;
+ }
+ }
+
+ throw new InvalidArgumentException(sprintf('gRPC resolved configuration key "%s" must be an integer.', $key));
+ }
+
+ private static function nullableInt(mixed $value, string $key): ?int
+ {
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ if (is_int($value)) {
+ return $value;
+ }
+
+ if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) {
+ $parsed = filter_var($value, FILTER_VALIDATE_INT);
+ if (is_int($parsed)) {
+ return $parsed;
+ }
+ }
+
+ throw new InvalidArgumentException(sprintf('gRPC resolved configuration key "%s" must be an integer or null.', $key));
+ }
+
+ /**
+ * @param array $config
+ * @return array
+ */
+ private static function section(array $config, string $key): array
+ {
+ $value = $config[$key] ?? [];
+ if (!is_array($value)) {
+ throw new InvalidArgumentException(sprintf('gRPC resolved configuration section "%s" must be an array.', $key));
+ }
+
+ $section = [];
+ foreach ($value as $name => $item) {
+ if (is_string($name)) {
+ $section[$name] = $item;
+ }
+ }
+
+ return $section;
+ }
+
+ /**
+ * @param array $config
+ */
+ private function applyResolvedConfig(GrpcClient $client, array $config): GrpcClient
+ {
+ $retry = self::section($config, 'retry');
+ if (!self::bool($retry, 'enabled', false)) {
+ return $client;
+ }
+
+ $maxDelay = $retry['max_delay_ms'] ?? null;
+
+ return $client->withGrpcRetry(
+ GrpcRetryPolicy::standard(
+ self::int($retry, 'attempts', 3),
+ self::int($retry, 'base_delay_ms', 100),
+ self::nullableInt($maxDelay, 'max_delay_ms'),
+ self::float($retry, 'jitter_ratio', 0.0),
+ ),
+ $this->cancellation,
+ );
+ }
+}
diff --git a/src/Grpc/GrpcInboundDispatcher.php b/src/Grpc/GrpcInboundDispatcher.php
index c2b0730..414363d 100644
--- a/src/Grpc/GrpcInboundDispatcher.php
+++ b/src/Grpc/GrpcInboundDispatcher.php
@@ -7,14 +7,19 @@
use Closure;
use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher;
use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
-use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundExchange;
use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundHandlerInterface;
use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundRequest;
use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundResponse;
+use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundSource;
use Throwable;
final class GrpcInboundDispatcher
{
+ private readonly Clock $clock;
+
private readonly EventDispatcher $events;
/**
@@ -25,15 +30,19 @@ final class GrpcInboundDispatcher
/**
* @param array $handlers
*/
- public function __construct(array $handlers = [], ?EventDispatcher $events = null)
- {
+ public function __construct(
+ array $handlers = [],
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ) {
$normalized = [];
foreach ($handlers as $method => $handler) {
$normalized[GrpcMethodGuard::normalize($method)] = Closure::fromCallable($handler);
}
$this->handlers = $normalized;
- $this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ $this->clock = $clock ?? Clock::system();
+ $this->events = BestEffortEventDispatcher::wrap($events);
}
public static function new(): self
@@ -43,7 +52,7 @@ public static function new(): self
public function handle(GrpcInboundRequest $request): GrpcInboundResponse
{
- $startedAt = microtime(true);
+ $startedAt = $this->clock->monotonic();
$this->events->dispatch('grpc.inbound.start', [
'method' => $request->method,
'deadline_seconds' => $request->deadlineSeconds,
@@ -58,7 +67,7 @@ public function handle(GrpcInboundRequest $request): GrpcInboundResponse
'method' => $request->method,
'status_code' => $response->status->value,
'status_name' => $response->status->name,
- 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000),
+ 'duration_ms' => (int) (($this->clock->monotonic() - $startedAt) * 1000),
]);
return $response;
@@ -67,27 +76,21 @@ public function handle(GrpcInboundRequest $request): GrpcInboundResponse
try {
$response = $handler($request);
} catch (Throwable $exception) {
- $durationMs = (int) ((microtime(true) - $startedAt) * 1000);
+ $durationMs = (int) (($this->clock->monotonic() - $startedAt) * 1000);
$this->events->dispatch('grpc.inbound.failed', [
'method' => $request->method,
'duration_ms' => $durationMs,
'exception' => $exception::class,
]);
- return new GrpcInboundResponse(
- status: GrpcStatus::Internal,
- message: 'Inbound gRPC handler failed.',
- metadata: [
- 'exception' => $exception::class,
- ],
- );
+ return GrpcInboundResponse::internal();
}
$this->events->dispatch('grpc.inbound.finish', [
'method' => $request->method,
'status_code' => $response->status->value,
'status_name' => $response->status->name,
- 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000),
+ 'duration_ms' => (int) (($this->clock->monotonic() - $startedAt) * 1000),
]);
return $response;
@@ -102,6 +105,22 @@ public function receive(
return $this->handle(new GrpcInboundRequest($method, $message, $headers, $deadlineSeconds));
}
+ public function serveOne(
+ GrpcInboundSource $source,
+ ?CancellationSignal $cancellation = null,
+ ): bool {
+ if ($cancellation?->isRequested() === true) {
+ return false;
+ }
+
+ $exchange = $source->accept($cancellation);
+ if ($exchange === null) {
+ return false;
+ }
+
+ return $this->completeAcceptedExchange($exchange, $cancellation);
+ }
+
/**
* @param callable(GrpcInboundRequest):GrpcInboundResponse|GrpcInboundHandlerInterface $handler
*/
@@ -114,6 +133,21 @@ public function withHandler(string $method, callable|GrpcInboundHandlerInterface
? $handler->handle(...)
: Closure::fromCallable($handler);
- return new self($handlers, $this->events);
+ return new self($handlers, $this->events, $this->clock);
+ }
+
+ private function completeAcceptedExchange(
+ GrpcInboundExchange $exchange,
+ ?CancellationSignal $cancellation,
+ ): bool {
+ if ($cancellation?->isRequested() === true) {
+ $exchange->complete(GrpcInboundResponse::cancelled());
+
+ return true;
+ }
+
+ $exchange->complete($this->handle($exchange->request()));
+
+ return true;
}
}
diff --git a/src/Grpc/Middleware/RetryMiddleware.php b/src/Grpc/Middleware/RetryMiddleware.php
index cefb284..43cdcc5 100644
--- a/src/Grpc/Middleware/RetryMiddleware.php
+++ b/src/Grpc/Middleware/RetryMiddleware.php
@@ -6,6 +6,7 @@
use Closure;
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
use Infocyph\TalkingBytes\Core\Support\Clock;
use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Grpc\Contract\GrpcMiddleware;
@@ -21,6 +22,7 @@
public function __construct(
private RetryPolicy $policy,
+ private ?CancellationSignal $cancellation = null,
?Clock $clock = null,
?Sleeper $sleeper = null,
) {
@@ -37,6 +39,10 @@ public function handle(GrpcRequest $request, Closure $next): CommunicationResult
$startedAt = $this->clock->monotonic();
$attempt = 1;
while (true) {
+ if ($this->cancellation?->isRequested() === true) {
+ return $this->cancelled($attempt - 1);
+ }
+
$attemptRequest = $this->withRemainingDeadline($request, $startedAt);
$result = $next($attemptRequest);
$decision = $this->policy->decide(new RetryContext($attempt, $result));
@@ -44,11 +50,27 @@ public function handle(GrpcRequest $request, Closure $next): CommunicationResult
return $result;
}
- $this->sleeper->milliseconds($decision->delayMs);
+ if ($this->cancellation === null) {
+ $this->sleeper->milliseconds($decision->delayMs);
+ } elseif (!$this->sleeper->millisecondsInterruptibly($decision->delayMs, $this->cancellation)) {
+ return $this->cancelled($attempt);
+ }
$attempt++;
}
}
+ private function cancelled(int $attempts): CommunicationResult
+ {
+ return CommunicationResult::failure(
+ 'gRPC operation cancelled.',
+ metadata: [
+ 'cancelled' => true,
+ 'attempts' => max(0, $attempts),
+ 'transport' => 'grpc',
+ ],
+ );
+ }
+
private function delayFitsDeadline(GrpcRequest $request, float $startedAt, int $delayMs): bool
{
if ($request->deadlineSeconds === null) {
diff --git a/src/Grpc/Native/GeneratedStubGrpcInvoker.php b/src/Grpc/Native/GeneratedStubGrpcInvoker.php
index 2ceff7c..8bf38b0 100644
--- a/src/Grpc/Native/GeneratedStubGrpcInvoker.php
+++ b/src/Grpc/Native/GeneratedStubGrpcInvoker.php
@@ -4,19 +4,47 @@
namespace Infocyph\TalkingBytes\Grpc\Native;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
use Infocyph\TalkingBytes\Grpc\GrpcDeadline;
use Infocyph\TalkingBytes\Grpc\GrpcMetadata;
+use Infocyph\TalkingBytes\Grpc\GrpcMethodGuard;
use Infocyph\TalkingBytes\Grpc\GrpcStatus;
+use InvalidArgumentException;
+use ReflectionMethod;
+use ReflectionObject;
+use RuntimeException;
+use Throwable;
final readonly class GeneratedStubGrpcInvoker implements NativeGrpcInvoker, NativeGrpcStreamingInvoker
{
+ /** @var array */
+ private array $methodMap;
+
+ /** @var array */
+ private array $streamOpenArity;
+
/**
* @param array $methodMap Maps gRPC method path to stub method name.
*/
public function __construct(
private object $stubClient,
- private array $methodMap = [],
- ) {}
+ array $methodMap = [],
+ private ?CancellationSignal $cancellation = null,
+ ) {
+ $reflection = new ReflectionObject($this->stubClient);
+ $this->methodMap = $this->normalizeMethodMap($reflection, $methodMap);
+
+ $streamOpenArity = [];
+ foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ if ($method->isStatic()) {
+ continue;
+ }
+
+ $streamOpenArity[$method->getName()] = self::resolveStreamOpenArity($method);
+ }
+
+ $this->streamOpenArity = $streamOpenArity;
+ }
public function bidiStream(
string $method,
@@ -25,12 +53,22 @@ public function bidiStream(
callable $onMessage,
?float $deadlineSeconds = null,
): NativeGrpcResult {
+ $this->assertNotCancelled();
$call = $this->invokeStreamOpen($method, $headers, $deadlineSeconds);
- $this->writeMessages($call, $messages);
- $this->finishClientWrites($call);
- $this->drainInboundStream($call, $onMessage);
- return $this->finalizeCall($call);
+ try {
+ $this->writeMessages($call, $messages);
+ $this->assertNotCancelled();
+ $this->finishClientWrites($call);
+ $this->drainInboundStream($call, $onMessage);
+ $this->assertNotCancelled();
+
+ return $this->finalizeCall($call);
+ } catch (Throwable $exception) {
+ $this->cancelCall($call);
+
+ throw $exception;
+ }
}
public function clientStream(
@@ -39,11 +77,21 @@ public function clientStream(
GrpcMetadata $headers,
?float $deadlineSeconds = null,
): NativeGrpcResult {
+ $this->assertNotCancelled();
$call = $this->invokeStreamOpen($method, $headers, $deadlineSeconds);
- $this->writeMessages($call, $messages);
- $this->finishClientWrites($call);
- return $this->finalizeCall($call);
+ try {
+ $this->writeMessages($call, $messages);
+ $this->assertNotCancelled();
+ $this->finishClientWrites($call);
+ $this->assertNotCancelled();
+
+ return $this->finalizeCall($call);
+ } catch (Throwable $exception) {
+ $this->cancelCall($call);
+
+ throw $exception;
+ }
}
public function invoke(
@@ -71,6 +119,7 @@ public function serverStream(
callable $onMessage,
?float $deadlineSeconds = null,
): NativeGrpcResult {
+ $this->assertNotCancelled();
$call = $this->invokeStubMethod(
$this->resolveMethodName($method),
[
@@ -80,24 +129,66 @@ public function serverStream(
],
);
- $this->drainInboundStream($call, $onMessage);
+ try {
+ $this->drainInboundStream($call, $onMessage);
+ $this->assertNotCancelled();
- return $this->finalizeCall($call);
+ return $this->finalizeCall($call);
+ } catch (Throwable $exception) {
+ $this->cancelCall($call);
+
+ throw $exception;
+ }
+ }
+
+ private static function resolveStreamOpenArity(ReflectionMethod $method): int
+ {
+ if ($method->isVariadic()) {
+ return $method->getNumberOfParameters() >= 3 ? 3 : 2;
+ }
+
+ return match (true) {
+ $method->getNumberOfParameters() >= 3 => 3,
+ $method->getNumberOfParameters() === 2 => 2,
+ default => 0,
+ };
+ }
+
+ private function assertNotCancelled(): void
+ {
+ if ($this->cancellation?->isRequested() === true) {
+ throw new RuntimeException('gRPC stream operation cancelled.');
+ }
+ }
+
+ private function cancelCall(mixed $call): void
+ {
+ if (!is_object($call) || !method_exists($call, 'cancel')) {
+ return;
+ }
+
+ try {
+ $call->cancel();
+ } catch (Throwable) {
+ // Preserve the original stream failure/cancellation.
+ }
}
private function drainInboundStream(mixed $call, callable $onMessage): void
{
if (!is_object($call)) {
- throw new \RuntimeException('gRPC stream call result must be an object.');
+ throw new RuntimeException('gRPC stream call result must be an object.');
}
if (method_exists($call, 'responses')) {
+ $this->assertNotCancelled();
$responses = $call->responses();
if (!is_iterable($responses)) {
- throw new \RuntimeException('gRPC server stream responses() must return iterable.');
+ throw new RuntimeException('gRPC server stream responses() must return iterable.');
}
foreach ($responses as $response) {
+ $this->assertNotCancelled();
$onMessage($response);
}
@@ -106,18 +197,20 @@ private function drainInboundStream(mixed $call, callable $onMessage): void
if (method_exists($call, 'read')) {
while (true) {
+ $this->assertNotCancelled();
$response = $call->read();
if ($response === null) {
break;
}
+ $this->assertNotCancelled();
$onMessage($response);
}
return;
}
- throw new \RuntimeException('Unsupported gRPC stream call object: expected responses() or read().');
+ throw new RuntimeException('Unsupported gRPC stream call object: expected responses() or read().');
}
private function extractHeaders(mixed $call): GrpcMetadata
@@ -222,7 +315,7 @@ private function extractWaitStatusMetadata(mixed $status): array
private function finalizeCall(mixed $call): NativeGrpcResult
{
if (!is_object($call) || !method_exists($call, 'wait')) {
- throw new \RuntimeException('Unsupported gRPC call object: expected wait() method.');
+ throw new RuntimeException('Unsupported gRPC call object: expected wait() method.');
}
$wait = $call->wait();
@@ -295,14 +388,21 @@ private function fromNativeMetadata(mixed $metadata): GrpcMetadata
private function invokeStreamOpen(string $method, GrpcMetadata $headers, ?float $deadlineSeconds): mixed
{
$methodName = $this->resolveMethodName($method);
+ $arity = $this->streamOpenArity[$methodName] ?? 0;
+ if ($arity === 0) {
+ throw new RuntimeException(sprintf(
+ 'Unsupported generated gRPC stream signature for method "%s".',
+ $methodName,
+ ));
+ }
+
$metadata = $this->toNativeMetadata($headers);
$options = $this->toNativeOptions($deadlineSeconds);
+ $arguments = $arity === 3
+ ? [null, $metadata, $options]
+ : [$metadata, $options];
- try {
- return $this->invokeStubMethod($methodName, [$metadata, $options]);
- } catch (\ArgumentCountError|\TypeError) {
- return $this->invokeStubMethod($methodName, [null, $metadata, $options]);
- }
+ return $this->invokeStubMethod($methodName, $arguments);
}
/**
@@ -310,9 +410,9 @@ private function invokeStreamOpen(string $method, GrpcMetadata $headers, ?float
*/
private function invokeStubMethod(string $methodName, array $args): mixed
{
- if (!method_exists($this->stubClient, $methodName)) {
- throw new \RuntimeException(sprintf(
- 'Generated gRPC stub method "%s" was not found on %s.',
+ if (!is_callable([$this->stubClient, $methodName])) {
+ throw new RuntimeException(sprintf(
+ 'Generated gRPC stub method "%s" was not found or is not public on %s.',
$methodName,
$this->stubClient::class,
));
@@ -321,6 +421,49 @@ private function invokeStubMethod(string $methodName, array $args): mixed
return $this->stubClient->{$methodName}(...$args);
}
+ /**
+ * @param array $methodMap
+ * @return array
+ */
+ private function normalizeMethodMap(ReflectionObject $reflection, array $methodMap): array
+ {
+ $normalized = [];
+
+ foreach ($methodMap as $method => $stubMethod) {
+ if (!is_string($method) || !is_string($stubMethod) || trim($stubMethod) === '') {
+ throw new InvalidArgumentException('gRPC generated method maps require non-empty string keys and values.');
+ }
+
+ $normalizedMethod = GrpcMethodGuard::normalize($method);
+ if (isset($normalized[$normalizedMethod])) {
+ throw new InvalidArgumentException(sprintf(
+ 'Duplicate generated gRPC method mapping for "%s".',
+ $normalizedMethod,
+ ));
+ }
+
+ if (!$reflection->hasMethod($stubMethod)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Generated gRPC stub method "%s" was not found on %s.',
+ $stubMethod,
+ $this->stubClient::class,
+ ));
+ }
+
+ $reflected = $reflection->getMethod($stubMethod);
+ if (!$reflected->isPublic() || $reflected->isStatic()) {
+ throw new InvalidArgumentException(sprintf(
+ 'Generated gRPC stub method "%s" must be a public instance method.',
+ $stubMethod,
+ ));
+ }
+
+ $normalized[$normalizedMethod] = $stubMethod;
+ }
+
+ return $normalized;
+ }
+
/**
* @return list
*/
@@ -366,13 +509,13 @@ private function normalizeStatusArrayMetadata(array $status): array
private function resolveMethodName(string $method): string
{
- $mapped = $this->methodMap[$method] ?? null;
- if (is_string($mapped) && $mapped !== '') {
+ $normalized = GrpcMethodGuard::normalize($method);
+ $mapped = $this->methodMap[$normalized] ?? null;
+ if ($mapped !== null) {
return $mapped;
}
- $trimmed = ltrim($method, '/');
- $parts = explode('/', $trimmed);
+ $parts = explode('/', ltrim($normalized, '/'));
return $parts[array_key_last($parts)];
}
@@ -416,10 +559,11 @@ private function toNativeOptions(?float $deadlineSeconds): array
private function writeMessages(mixed $call, iterable $messages): void
{
if (!is_object($call) || !method_exists($call, 'write')) {
- throw new \RuntimeException('Unsupported client stream call object: expected write() method.');
+ throw new RuntimeException('Unsupported client stream call object: expected write() method.');
}
foreach ($messages as $message) {
+ $this->assertNotCancelled();
$call->write($message);
}
}
diff --git a/src/Grpc/Receiver/GrpcInboundExchange.php b/src/Grpc/Receiver/GrpcInboundExchange.php
new file mode 100644
index 0000000..a393d98
--- /dev/null
+++ b/src/Grpc/Receiver/GrpcInboundExchange.php
@@ -0,0 +1,12 @@
+caller = Closure::fromCallable($caller);
$this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ $this->clock = $clock ?? Clock::system();
}
public function send(GrpcRequest $grpcRequest): CommunicationResult
{
- $startedAt = microtime(true);
+ $startedAt = $this->clock->monotonic();
$this->events->dispatch('grpc.request.start', [
'transport' => 'grpc',
'method' => $grpcRequest->method,
@@ -43,12 +48,12 @@ public function send(GrpcRequest $grpcRequest): CommunicationResult
try {
$response = ($this->caller)($grpcRequest);
} catch (Throwable $exception) {
- $durationMs = (int) ((microtime(true) - $startedAt) * 1000);
+ $durationMs = (int) (($this->clock->monotonic() - $startedAt) * 1000);
$this->events->dispatch('grpc.request.failed', [
'transport' => 'grpc',
'method' => $grpcRequest->method,
'duration_ms' => $durationMs,
- 'error' => $exception->getMessage(),
+ ...ObservabilitySanitizer::throwableContext($exception),
]);
$error = new GrpcCallError(
@@ -75,7 +80,7 @@ public function send(GrpcRequest $grpcRequest): CommunicationResult
);
}
- $durationMs = (int) ((microtime(true) - $startedAt) * 1000);
+ $durationMs = (int) (($this->clock->monotonic() - $startedAt) * 1000);
if (!$response->isOk()) {
$this->events->dispatch('grpc.request.failed', [
'transport' => 'grpc',
diff --git a/src/Grpc/Testing/FakeGrpcInboundExchange.php b/src/Grpc/Testing/FakeGrpcInboundExchange.php
new file mode 100644
index 0000000..7f90507
--- /dev/null
+++ b/src/Grpc/Testing/FakeGrpcInboundExchange.php
@@ -0,0 +1,44 @@
+completed) {
+ throw new LogicException('Inbound gRPC exchange has already been completed.');
+ }
+
+ $this->response = $response;
+ $this->completed = true;
+ }
+
+ public function completed(): bool
+ {
+ return $this->completed;
+ }
+
+ public function request(): GrpcInboundRequest
+ {
+ return $this->inboundRequest;
+ }
+
+ public function response(): ?GrpcInboundResponse
+ {
+ return $this->response;
+ }
+}
diff --git a/src/Grpc/Testing/FakeGrpcInboundSource.php b/src/Grpc/Testing/FakeGrpcInboundSource.php
new file mode 100644
index 0000000..ff96c0b
--- /dev/null
+++ b/src/Grpc/Testing/FakeGrpcInboundSource.php
@@ -0,0 +1,54 @@
+ */
+ private array $accepted = [];
+
+ /** @var list */
+ private array $pending = [];
+
+ public function accept(?CancellationSignal $cancellation = null): ?GrpcInboundExchange
+ {
+ if ($cancellation?->isRequested() === true) {
+ return null;
+ }
+
+ $exchange = array_shift($this->pending);
+ if (!$exchange instanceof FakeGrpcInboundExchange) {
+ return null;
+ }
+
+ $this->accepted[] = $exchange;
+
+ return $exchange;
+ }
+
+ /** @return list */
+ public function accepted(): array
+ {
+ return $this->accepted;
+ }
+
+ public function enqueue(GrpcInboundRequest $request): FakeGrpcInboundExchange
+ {
+ $exchange = new FakeGrpcInboundExchange($request);
+ $this->pending[] = $exchange;
+
+ return $exchange;
+ }
+
+ public function pendingCount(): int
+ {
+ return count($this->pending);
+ }
+}
diff --git a/src/Http/Concurrent/CurlMultiTransport.php b/src/Http/Concurrent/CurlMultiTransport.php
index dd692b9..98f80c6 100644
--- a/src/Http/Concurrent/CurlMultiTransport.php
+++ b/src/Http/Concurrent/CurlMultiTransport.php
@@ -8,6 +8,9 @@
use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer;
use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Http\HttpRequest;
use Infocyph\TalkingBytes\Http\Internal\CurlHandleConfigurator;
@@ -21,21 +24,31 @@
final readonly class CurlMultiTransport
{
+ private Clock $clock;
+
private EventDispatcher $events;
private Sleeper $sleeper;
- public function __construct(?Sleeper $sleeper = null, ?EventDispatcher $events = null)
- {
+ public function __construct(
+ ?Sleeper $sleeper = null,
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ) {
$this->sleeper = $sleeper ?? Sleeper::system();
$this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ $this->clock = $clock ?? Clock::system();
}
/**
* @param array $requests
*/
- public function sendMany(array $requests, int $maxConcurrency = 10, bool $stopOnFailure = false): PoolResult
- {
+ public function sendMany(
+ array $requests,
+ int $maxConcurrency = 10,
+ bool $stopOnFailure = false,
+ ?CancellationSignal $cancellation = null,
+ ): PoolResult {
$this->events->dispatch('http.pool.start', [
'request_count' => count($requests),
'max_concurrency' => $maxConcurrency,
@@ -44,61 +57,207 @@ public function sendMany(array $requests, int $maxConcurrency = 10, bool $stopOn
]);
$limit = new ConcurrencyLimit($maxConcurrency);
- $chunkSize = max(1, $limit->value);
+ $startedAt = $this->clock->monotonic();
+ $keys = array_keys($requests);
+
+ /** @var array $results */
$results = [];
- $start = microtime(true);
- foreach (array_chunk($requests, $chunkSize, true) as $chunk) {
- $chunkResults = $this->sendChunk($chunk);
- foreach ($chunkResults as $key => $result) {
- $results[$key] = $result;
- }
+ if ($keys === []) {
+ return $this->finishPool($requests, $results, $startedAt, false, false);
+ }
+
+ $multiHandle = curl_multi_init();
+ $configurator = new CurlHandleConfigurator();
+
+ /**
+ * @var array $contexts
+ */
+ $contexts = [];
+
+ $nextIndex = 0;
+ $stoppedScheduling = false;
+ $cancelled = false;
+
+ try {
+ while (true) {
+ $scheduled = [
+ 'next_index' => $nextIndex,
+ 'stopped' => false,
+ 'cancelled' => false,
+ ];
+
+ if (!$stoppedScheduling) {
+ $scheduled = $this->scheduleAvailable(
+ $multiHandle,
+ $configurator,
+ $requests,
+ $keys,
+ $nextIndex,
+ $limit->value,
+ $results,
+ $contexts,
+ $stopOnFailure,
+ $cancellation,
+ );
+ $nextIndex = $scheduled['next_index'];
+ $stoppedScheduling = $scheduled['stopped'];
+ }
+
+ if ($scheduled['cancelled']) {
+ $cancelled = true;
+ $stoppedScheduling = true;
+ $this->cancelOutstanding($multiHandle, $keys, $nextIndex, $contexts, $results);
+
+ break;
+ }
- if ($stopOnFailure && $this->containsFailure($chunkResults)) {
- $pool = new PoolResult(
+ if ($contexts === []) {
+ break;
+ }
+
+ $execution = $this->executeMulti($multiHandle);
+ if ($execution['error'] !== null) {
+ $stoppedScheduling = true;
+ $this->failOutstanding(
+ $multiHandle,
+ $keys,
+ $nextIndex,
+ $contexts,
+ $results,
+ $execution['error'],
+ );
+
+ break;
+ }
+
+ $completed = $this->collectCompleted(
+ $multiHandle,
+ $contexts,
$results,
- ['duration_ms' => (int) ((microtime(true) - $start) * 1000), 'stopped_scheduling' => true],
+ $stopOnFailure,
);
- $this->events->dispatch('http.pool.finish', [
- 'request_count' => count($requests),
- 'successful_count' => $pool->successfulCount(),
- 'failed_count' => $pool->failedCount(),
- 'duration_ms' => $pool->metadata['duration_ms'] ?? null,
- 'stopped_scheduling' => true,
- 'transport' => 'curl-multi',
- ]);
+ $stoppedScheduling = $stoppedScheduling || $completed['failure_observed'];
+
+ if ($cancellation?->isRequested() === true) {
+ $cancelled = true;
+ $stoppedScheduling = true;
+ $this->cancelOutstanding($multiHandle, $keys, $nextIndex, $contexts, $results);
- return $pool;
+ break;
+ }
+
+ if ($contexts !== [] && $completed['count'] === 0 && $execution['running'] > 0) {
+ $this->waitForActivity($multiHandle, $cancellation);
+ }
}
+ } finally {
+ foreach ($contexts as $context) {
+ $this->abortContext($multiHandle, $context);
+ }
+
+ curl_multi_close($multiHandle);
}
- $pool = new PoolResult(
- $results,
- ['duration_ms' => (int) ((microtime(true) - $start) * 1000), 'stopped_scheduling' => false],
+ return $this->finishPool(
+ $requests,
+ $this->orderResults($keys, $results),
+ $startedAt,
+ $stoppedScheduling,
+ $cancelled,
);
- $this->events->dispatch('http.pool.finish', [
- 'request_count' => count($requests),
- 'successful_count' => $pool->successfulCount(),
- 'failed_count' => $pool->failedCount(),
- 'duration_ms' => $pool->metadata['duration_ms'] ?? null,
- 'stopped_scheduling' => false,
- 'transport' => 'curl-multi',
- ]);
+ }
- return $pool;
+ private static function cancelledResult(bool $started): CommunicationResult
+ {
+ return CommunicationResult::failure(
+ 'HTTP concurrent operation cancelled.',
+ metadata: [
+ 'transport' => 'curl-multi',
+ 'cancelled' => true,
+ 'started' => $started,
+ ],
+ );
}
- private function cleanupUploadHandle(HttpRequest $request): void
+ /**
+ * @param array{key:int|string, handle:\CurlHandle, request:HttpRequest, headerCollector:ResponseHeaderCollector, bodyCollector:ResponseBodyCollector} $context
+ */
+ private function abortContext(\CurlMultiHandle $multiHandle, array $context): void
{
- UploadHandleManager::cleanup($request);
+ $context['bodyCollector']->abort();
+ UploadHandleManager::cleanup($context['request']);
+ curl_multi_remove_handle($multiHandle, $context['handle']);
}
/**
+ * @param list $keys
+ * @param array $contexts
* @param array $results
*/
- private function containsFailure(array $results): bool
- {
- return array_any($results, static fn(CommunicationResult $result): bool => !$result->successful);
+ private function cancelOutstanding(
+ \CurlMultiHandle $multiHandle,
+ array $keys,
+ int $nextIndex,
+ array &$contexts,
+ array &$results,
+ ): void {
+ foreach ($contexts as $handleId => $context) {
+ $result = self::cancelledResult(started: true);
+ $results[$context['key']] = $result;
+ $this->dispatchRequestResultEvent($context['request'], $result);
+ $this->abortContext($multiHandle, $context);
+ unset($contexts[$handleId]);
+ }
+
+ $total = count($keys);
+ for ($index = $nextIndex; $index < $total; $index++) {
+ $key = $keys[$index];
+ if (!array_key_exists($key, $results)) {
+ $results[$key] = self::cancelledResult(started: false);
+ }
+ }
+ }
+
+ /**
+ * @param array $contexts
+ * @param array $results
+ * @return array{count:int, failure_observed:bool}
+ */
+ private function collectCompleted(
+ \CurlMultiHandle $multiHandle,
+ array &$contexts,
+ array &$results,
+ bool $stopOnFailure,
+ ): array {
+ $count = 0;
+ $failureObserved = false;
+
+ while (($message = curl_multi_info_read($multiHandle)) !== false) {
+ $handle = $message['handle'] ?? null;
+ if (!$handle instanceof \CurlHandle) {
+ continue;
+ }
+
+ $handleId = spl_object_id($handle);
+ $context = $contexts[$handleId] ?? null;
+ if ($context === null) {
+ continue;
+ }
+
+ $result = $this->finalizeContext($context);
+ $results[$context['key']] = $result;
+ $this->dispatchRequestResultEvent($context['request'], $result);
+ $this->releaseContext($multiHandle, $context);
+ unset($contexts[$handleId]);
+
+ $count++;
+ if ($stopOnFailure && !$result->successful) {
+ $failureObserved = true;
+ }
+ }
+
+ return ['count' => $count, 'failure_observed' => $failureObserved];
}
private function dispatchRequestResultEvent(HttpRequest $request, CommunicationResult $result): void
@@ -107,13 +266,67 @@ private function dispatchRequestResultEvent(HttpRequest $request, CommunicationR
'method' => $request->method->value,
'url' => HttpRedactor::redactUrl($request->buildUrl()),
'status' => $result->statusCode,
- 'error' => $result->error,
+ 'failure_category' => $result->successful ? null : (ObservabilitySanitizer::resultContext($result)['failure_category'] ?? 'transport_error'),
'transport' => 'curl-multi',
]);
}
/**
- * @param array{handle: \CurlHandle, request: HttpRequest, headerCollector: ResponseHeaderCollector, bodyCollector: ResponseBodyCollector} $context
+ * @return array{error:?string, running:int}
+ */
+ private function executeMulti(\CurlMultiHandle $multiHandle): array
+ {
+ $status = curl_multi_exec($multiHandle, $running);
+ if ($status !== CURLM_OK) {
+ return [
+ 'error' => sprintf('cURL multi execution failed with status %d.', $status),
+ 'running' => 0,
+ ];
+ }
+
+ return ['error' => null, 'running' => is_int($running) ? $running : 0];
+ }
+
+ /**
+ * @param list $keys
+ * @param array $contexts
+ * @param array $results
+ */
+ private function failOutstanding(
+ \CurlMultiHandle $multiHandle,
+ array $keys,
+ int $nextIndex,
+ array &$contexts,
+ array &$results,
+ string $error,
+ ): void {
+ foreach ($contexts as $handleId => $context) {
+ $result = CommunicationResult::failure($error, metadata: [
+ 'transport' => 'curl-multi',
+ 'scheduler_error' => true,
+ 'started' => true,
+ ]);
+ $results[$context['key']] = $result;
+ $this->dispatchRequestResultEvent($context['request'], $result);
+ $this->abortContext($multiHandle, $context);
+ unset($contexts[$handleId]);
+ }
+
+ $total = count($keys);
+ for ($index = $nextIndex; $index < $total; $index++) {
+ $key = $keys[$index];
+ if (!array_key_exists($key, $results)) {
+ $results[$key] = CommunicationResult::failure($error, metadata: [
+ 'transport' => 'curl-multi',
+ 'scheduler_error' => true,
+ 'started' => false,
+ ]);
+ }
+ }
+ }
+
+ /**
+ * @param array{key:int|string, handle:\CurlHandle, request:HttpRequest, headerCollector:ResponseHeaderCollector, bodyCollector:ResponseBodyCollector} $context
*/
private function finalizeContext(array $context): CommunicationResult
{
@@ -122,8 +335,6 @@ private function finalizeContext(array $context): CommunicationResult
$streamFinalizeError = $context['bodyCollector']->finalize();
$body = is_string($rawBody) ? $rawBody : $context['bodyCollector']->responseBody();
- $this->cleanupUploadHandle($context['request']);
-
if ($streamFinalizeError !== null) {
return CommunicationResult::failure(
$streamFinalizeError,
@@ -161,8 +372,55 @@ private function finalizeContext(array $context): CommunicationResult
}
/**
+ * @param array $requests
* @param array $results
- * @return array{handle: \CurlHandle, request: HttpRequest, headerCollector: ResponseHeaderCollector, bodyCollector: ResponseBodyCollector}|null
+ */
+ private function finishPool(
+ array $requests,
+ array $results,
+ float $startedAt,
+ bool $stoppedScheduling,
+ bool $cancelled,
+ ): PoolResult {
+ $pool = new PoolResult($results, [
+ 'duration_ms' => (int) (($this->clock->monotonic() - $startedAt) * 1000),
+ 'stopped_scheduling' => $stoppedScheduling,
+ 'cancelled' => $cancelled,
+ ]);
+
+ $this->events->dispatch('http.pool.finish', [
+ 'request_count' => count($requests),
+ 'successful_count' => $pool->successfulCount(),
+ 'failed_count' => $pool->failedCount(),
+ 'duration_ms' => $pool->metadata['duration_ms'],
+ 'stopped_scheduling' => $stoppedScheduling,
+ 'cancelled' => $cancelled,
+ 'transport' => 'curl-multi',
+ ]);
+
+ return $pool;
+ }
+
+ /**
+ * @param list $keys
+ * @param array $results
+ * @return array
+ */
+ private function orderResults(array $keys, array $results): array
+ {
+ $ordered = [];
+ foreach ($keys as $key) {
+ if (array_key_exists($key, $results)) {
+ $ordered[$key] = $results[$key];
+ }
+ }
+
+ return $ordered;
+ }
+
+ /**
+ * @param array $results
+ * @return array{handle:\CurlHandle, request:HttpRequest, headerCollector:ResponseHeaderCollector, bodyCollector:ResponseBodyCollector}|null
*/
private function prepareContext(
\CurlMultiHandle $multiHandle,
@@ -182,14 +440,20 @@ private function prepareContext(
}
$pinnedResolution = RequestSecurityGuard::pinnedResolution($prepared, $prepared->buildUrl());
} catch (InvalidArgumentException $exception) {
- $results[$index] = CommunicationResult::failure($exception->getMessage(), metadata: ['transport' => 'curl-multi']);
+ $results[$index] = CommunicationResult::failure(
+ $exception->getMessage(),
+ metadata: ['transport' => 'curl-multi'],
+ );
return null;
}
$handle = curl_init();
if ($handle === false) {
- $results[$index] = CommunicationResult::failure('Unable to initialize cURL handle.');
+ $results[$index] = CommunicationResult::failure(
+ 'Unable to initialize cURL handle.',
+ metadata: ['transport' => 'curl-multi'],
+ );
return null;
}
@@ -201,9 +465,13 @@ private function prepareContext(
$bodyCollector = new ResponseBodyCollector($prepared, $collector);
$prepared = $configurator->configure($handle, $prepared, $collector, $bodyCollector, $pinnedResolution);
} catch (InvalidArgumentException $exception) {
- $bodyCollector?->finalize();
+ $bodyCollector?->abort();
+ UploadHandleManager::cleanup($prepared);
unset($handle);
- $results[$index] = CommunicationResult::failure($exception->getMessage());
+ $results[$index] = CommunicationResult::failure(
+ $exception->getMessage(),
+ metadata: ['transport' => 'curl-multi'],
+ );
return null;
}
@@ -217,11 +485,12 @@ private function prepareContext(
$status = curl_multi_add_handle($multiHandle, $handle);
if ($status !== CURLM_OK) {
- $bodyCollector->finalize();
+ $bodyCollector->abort();
UploadHandleManager::cleanup($prepared);
unset($handle);
$results[$index] = CommunicationResult::failure(
sprintf('Unable to add request to cURL multi handle (%d).', $status),
+ metadata: ['transport' => 'curl-multi'],
);
return null;
@@ -235,74 +504,83 @@ private function prepareContext(
];
}
- private function runMultiLoop(\CurlMultiHandle $multiHandle): ?string
+ /**
+ * @param array{key:int|string, handle:\CurlHandle, request:HttpRequest, headerCollector:ResponseHeaderCollector, bodyCollector:ResponseBodyCollector} $context
+ */
+ private function releaseContext(\CurlMultiHandle $multiHandle, array $context): void
{
- do {
- $status = curl_multi_exec($multiHandle, $running);
- if ($status !== CURLM_OK) {
- return sprintf('cURL multi execution failed with status %d.', $status);
- }
-
- if ($running > 0 && curl_multi_select($multiHandle, 1.0) === -1) {
- $this->sleeper->milliseconds(10);
- }
- } while ($running > 0);
-
- return null;
+ UploadHandleManager::cleanup($context['request']);
+ curl_multi_remove_handle($multiHandle, $context['handle']);
}
/**
* @param array $requests
- * @return array
+ * @param list $keys
+ * @param array $results
+ * @param array $contexts
+ * @return array{next_index:int, stopped:bool, cancelled:bool}
*/
- private function sendChunk(array $requests): array
- {
- $multiHandle = curl_multi_init();
-
- /**
- * @var array $contexts
- */
- $contexts = [];
-
- /** @var array $results */
- $results = [];
+ private function scheduleAvailable(
+ \CurlMultiHandle $multiHandle,
+ CurlHandleConfigurator $configurator,
+ array $requests,
+ array $keys,
+ int $nextIndex,
+ int $maxConcurrency,
+ array &$results,
+ array &$contexts,
+ bool $stopOnFailure,
+ ?CancellationSignal $cancellation,
+ ): array {
+ $total = count($keys);
+
+ while ($nextIndex < $total && count($contexts) < $maxConcurrency) {
+ if ($cancellation?->isRequested() === true) {
+ return ['next_index' => $nextIndex, 'stopped' => true, 'cancelled' => true];
+ }
- $configurator = new CurlHandleConfigurator();
+ $key = $keys[$nextIndex];
+ $nextIndex++;
+ $context = $this->prepareContext(
+ $multiHandle,
+ $configurator,
+ $requests[$key],
+ $results,
+ $key,
+ );
- try {
- foreach ($requests as $index => $request) {
- $context = $this->prepareContext($multiHandle, $configurator, $request, $results, $index);
- if ($context === null) {
- continue;
+ if ($context === null) {
+ if ($stopOnFailure && isset($results[$key]) && !$results[$key]->successful) {
+ return ['next_index' => $nextIndex, 'stopped' => true, 'cancelled' => false];
}
- $contexts[$index] = $context;
+ continue;
}
- $multiError = $this->runMultiLoop($multiHandle);
+ $contexts[spl_object_id($context['handle'])] = [
+ 'key' => $key,
+ ...$context,
+ ];
+ }
- foreach ($contexts as $index => $context) {
- $results[$index] = $multiError === null
- ? $this->finalizeContext($context)
- : CommunicationResult::failure($multiError, metadata: ['transport' => 'curl-multi']);
- $this->dispatchRequestResultEvent($context['request'], $results[$index]);
- }
- } finally {
- foreach ($contexts as $context) {
- $context['bodyCollector']->finalize();
- UploadHandleManager::cleanup($context['request']);
- curl_multi_remove_handle($multiHandle, $context['handle']);
- }
- curl_multi_close($multiHandle);
+ return ['next_index' => $nextIndex, 'stopped' => false, 'cancelled' => false];
+ }
+
+ private function waitForActivity(
+ \CurlMultiHandle $multiHandle,
+ ?CancellationSignal $cancellation,
+ ): void {
+ $timeoutSeconds = $cancellation === null ? 1.0 : 0.05;
+ if (curl_multi_select($multiHandle, $timeoutSeconds) !== -1) {
+ return;
}
- $orderedResults = [];
- foreach (array_keys($requests) as $key) {
- if (array_key_exists($key, $results)) {
- $orderedResults[$key] = $results[$key];
- }
+ if ($cancellation === null) {
+ $this->sleeper->milliseconds(10);
+
+ return;
}
- return $orderedResults;
+ $this->sleeper->millisecondsInterruptibly(10, $cancellation, 10);
}
}
diff --git a/src/Http/Concurrent/RequestPool.php b/src/Http/Concurrent/RequestPool.php
index 86d2847..c6a2163 100644
--- a/src/Http/Concurrent/RequestPool.php
+++ b/src/Http/Concurrent/RequestPool.php
@@ -4,6 +4,7 @@
namespace Infocyph\TalkingBytes\Http\Concurrent;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
use Infocyph\TalkingBytes\Http\HttpRequest;
final readonly class RequestPool
@@ -12,11 +13,17 @@ public function __construct(
private CurlMultiTransport $transport,
private int $maxConcurrency = 10,
private bool $stopOnFailure = false,
+ private ?CancellationSignal $cancellation = null,
) {}
public function maxConcurrency(int $maxConcurrency): self
{
- return new self($this->transport, $maxConcurrency, $this->stopOnFailure);
+ return new self(
+ $this->transport,
+ $maxConcurrency,
+ $this->stopOnFailure,
+ $this->cancellation,
+ );
}
/**
@@ -24,11 +31,31 @@ public function maxConcurrency(int $maxConcurrency): self
*/
public function sendMany(array $requests): PoolResult
{
- return $this->transport->sendMany($requests, $this->maxConcurrency, $this->stopOnFailure);
+ return $this->transport->sendMany(
+ $requests,
+ $this->maxConcurrency,
+ $this->stopOnFailure,
+ $this->cancellation,
+ );
}
public function stopSchedulingOnFailure(bool $enabled = true): self
{
- return new self($this->transport, $this->maxConcurrency, $enabled);
+ return new self(
+ $this->transport,
+ $this->maxConcurrency,
+ $enabled,
+ $this->cancellation,
+ );
+ }
+
+ public function withCancellation(?CancellationSignal $cancellation): self
+ {
+ return new self(
+ $this->transport,
+ $this->maxConcurrency,
+ $this->stopOnFailure,
+ $cancellation,
+ );
}
}
diff --git a/src/Http/HttpClient.php b/src/Http/HttpClient.php
index f99a865..8f1092e 100644
--- a/src/Http/HttpClient.php
+++ b/src/Http/HttpClient.php
@@ -12,6 +12,8 @@
use Infocyph\TalkingBytes\Auth\SignedRequestAuth;
use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
+use Infocyph\TalkingBytes\Core\Support\Clock;
use Infocyph\TalkingBytes\Http\Body\MultipartBody;
use Infocyph\TalkingBytes\Http\Contract\HttpMiddleware;
use Infocyph\TalkingBytes\Http\Contract\HttpTransport;
@@ -53,9 +55,9 @@ private function __construct(
$this->pipeline = new HttpPipeline($transport, $middlewares);
}
- public static function curl(?EventDispatcher $events = null): self
+ public static function curl(?EventDispatcher $events = null, ?Clock $clock = null): self
{
- return new self(new CurlTransport($events));
+ return new self(new CurlTransport($events, $clock));
}
public static function fake(?FakeHttpTransport $transport = null): self
@@ -63,10 +65,14 @@ public static function fake(?FakeHttpTransport $transport = null): self
return new self($transport ?? new FakeHttpTransport());
}
- public static function fromConfig(HttpClientConfig $config): self
- {
+ public static function fromConfig(
+ HttpClientConfig $config,
+ ?EventDispatcher $events = null,
+ ?HttpTransport $transport = null,
+ ?Clock $clock = null,
+ ): self {
return new self(
- transport: new CurlTransport(),
+ transport: $transport ?? new CurlTransport($events, $clock),
defaultOptions: new CurlOptions(
timeoutSeconds: $config->timeoutSeconds,
connectTimeoutSeconds: $config->connectTimeoutSeconds,
@@ -86,9 +92,29 @@ public static function fromConfig(HttpClientConfig $config): self
);
}
- public static function multi(int $maxConcurrency = 10, ?EventDispatcher $events = null): Concurrent\RequestPool
- {
- return new Concurrent\RequestPool(new Concurrent\CurlMultiTransport(events: $events), $maxConcurrency);
+ /**
+ * @param array $config
+ */
+ public static function fromResolvedConfig(
+ array $config,
+ ?EventDispatcher $events = null,
+ ?CancellationSignal $cancellation = null,
+ ?HttpTransport $transport = null,
+ ?Clock $clock = null,
+ ): self {
+ return new HttpClientFactory($events, $cancellation, $clock)->fromArray($config, $transport);
+ }
+
+ public static function multi(
+ int $maxConcurrency = 10,
+ ?EventDispatcher $events = null,
+ ?CancellationSignal $cancellation = null,
+ ): Concurrent\RequestPool {
+ return new Concurrent\RequestPool(
+ new Concurrent\CurlMultiTransport(events: $events),
+ $maxConcurrency,
+ cancellation: $cancellation,
+ );
}
public static function multipart(): MultipartBody
@@ -296,9 +322,11 @@ public function withHeaders(array $headers): self
return new self($this->transport, $this->middlewares, $this->defaultOptions, $headers, $this->authenticators, $this->cookieJar);
}
- public function withHttpRetry(?HttpRetryPolicy $policy = null): self
- {
- return $this->withRetry($policy ?? HttpRetryPolicy::standard());
+ public function withHttpRetry(
+ ?HttpRetryPolicy $policy = null,
+ ?CancellationSignal $cancellation = null,
+ ): self {
+ return $this->withRetry($policy ?? HttpRetryPolicy::standard(), $cancellation);
}
public function withIdempotency(string $headerName = 'Idempotency-Key'): self
@@ -332,9 +360,9 @@ public function withRateLimit(RateLimiter $rateLimiter): self
return $this->withMiddleware(new RateLimitMiddleware($rateLimiter));
}
- public function withRetry(RetryPolicy $policy): self
+ public function withRetry(RetryPolicy $policy, ?CancellationSignal $cancellation = null): self
{
- return $this->withMiddleware(new RetryMiddleware($policy));
+ return $this->withMiddleware(new RetryMiddleware($policy, $cancellation));
}
public function withSigner(RequestSigner $signer): self
diff --git a/src/Http/HttpClientFactory.php b/src/Http/HttpClientFactory.php
new file mode 100644
index 0000000..db79866
--- /dev/null
+++ b/src/Http/HttpClientFactory.php
@@ -0,0 +1,197 @@
+ $config
+ */
+ public function fromArray(array $config, ?HttpTransport $transport = null): HttpClient
+ {
+ $client = HttpClient::fromConfig(
+ HttpClientConfig::fromArray($config),
+ $this->events,
+ $transport,
+ $this->clock,
+ );
+
+ $client = $this->applyAuth($client, self::section($config, 'auth'));
+
+ if (self::enabled(self::section($config, 'cookies'))) {
+ $client = $client->withCookieJar(new CookieJar());
+ }
+
+ $retry = self::section($config, 'retry');
+ if (self::enabled($retry)) {
+ $client = $client->withHttpRetry(
+ HttpRetryPolicy::standard(
+ self::int($retry, 'attempts', 3),
+ self::int($retry, 'base_delay_ms', 250),
+ self::int($retry, 'max_retry_after_seconds', 30),
+ ),
+ $this->cancellation,
+ );
+ }
+
+ $rateLimit = self::section($config, 'rate_limit');
+ if (self::enabled($rateLimit)) {
+ $client = $client->withRateLimit(new RateLimiter(
+ self::int($rateLimit, 'max_requests', 60),
+ self::int($rateLimit, 'per_seconds', 60),
+ ));
+ }
+
+ $circuit = self::section($config, 'circuit_breaker');
+ if (self::enabled($circuit)) {
+ $client = $client->withCircuitBreaker(new CircuitBreaker(
+ self::int($circuit, 'failure_threshold', 5),
+ self::int($circuit, 'cool_down_seconds', 30),
+ ));
+ }
+
+ $idempotency = self::section($config, 'idempotency');
+ if (self::enabled($idempotency)) {
+ $client = $client->withIdempotency(
+ self::string($idempotency, 'header', 'Idempotency-Key', true),
+ );
+ }
+
+ return $client;
+ }
+
+ /** @param array $config */
+ private static function bool(array $config, string $key, bool $default): bool
+ {
+ if (!array_key_exists($key, $config)) {
+ return $default;
+ }
+
+ $value = $config[$key];
+ if (is_bool($value)) {
+ return $value;
+ }
+
+ if (is_int($value) && ($value === 0 || $value === 1)) {
+ return $value === 1;
+ }
+
+ if (is_string($value)) {
+ $parsed = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
+ if (is_bool($parsed)) {
+ return $parsed;
+ }
+ }
+
+ throw new InvalidArgumentException(sprintf('HTTP resolved configuration key "%s" must be a boolean.', $key));
+ }
+
+ /** @param array $config */
+ private static function enabled(array $config): bool
+ {
+ return self::bool($config, 'enabled', false);
+ }
+
+ /** @param array $config */
+ private static function int(array $config, string $key, int $default): int
+ {
+ $value = $config[$key] ?? $default;
+ if (is_int($value)) {
+ return $value;
+ }
+
+ if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) {
+ $parsed = filter_var($value, FILTER_VALIDATE_INT);
+ if (is_int($parsed)) {
+ return $parsed;
+ }
+ }
+
+ throw new InvalidArgumentException(sprintf('HTTP resolved configuration key "%s" must be an integer.', $key));
+ }
+
+ /**
+ * @param array $config
+ * @return array
+ */
+ private static function section(array $config, string $key): array
+ {
+ $value = $config[$key] ?? [];
+ if (!is_array($value)) {
+ throw new InvalidArgumentException(sprintf('HTTP resolved configuration section "%s" must be an array.', $key));
+ }
+
+ $section = [];
+ foreach ($value as $name => $item) {
+ if (is_string($name)) {
+ $section[$name] = $item;
+ }
+ }
+
+ return $section;
+ }
+
+ /** @param array $config */
+ private static function string(
+ array $config,
+ string $key,
+ string $default = '',
+ bool $required = false,
+ ): string {
+ $value = $config[$key] ?? $default;
+ if (!is_string($value)) {
+ throw new InvalidArgumentException(sprintf('HTTP resolved configuration key "%s" must be a string.', $key));
+ }
+
+ $value = trim($value);
+ if ($required && $value === '') {
+ throw new InvalidArgumentException(sprintf('HTTP resolved configuration key "%s" must be non-empty.', $key));
+ }
+
+ return $value;
+ }
+
+ /**
+ * @param array $auth
+ */
+ private function applyAuth(HttpClient $client, array $auth): HttpClient
+ {
+ return match (self::string($auth, 'driver', 'none')) {
+ 'api_key', 'api_key_header', 'api-key-header', 'header' => $client->withApiKeyHeader(
+ self::string($auth, 'header', 'X-Api-Key', true),
+ self::string($auth, 'value', '', true),
+ ),
+ 'api_key_query', 'api-key-query', 'query' => $client->withApiKeyQuery(
+ self::string($auth, 'query_key', 'api_key', true),
+ self::string($auth, 'value', '', true),
+ ),
+ 'basic' => $client->withBasicAuth(
+ self::string($auth, 'username', '', true),
+ self::string($auth, 'password', '', true),
+ ),
+ 'bearer' => $client->withBearerToken(self::string($auth, 'token', '', true)),
+ 'none', '' => $client,
+ default => throw new InvalidArgumentException('Unsupported HTTP auth driver.'),
+ };
+ }
+}
diff --git a/src/Http/Internal/ResponseBodyCollector.php b/src/Http/Internal/ResponseBodyCollector.php
index f39d464..42faf3f 100644
--- a/src/Http/Internal/ResponseBodyCollector.php
+++ b/src/Http/Internal/ResponseBodyCollector.php
@@ -33,6 +33,21 @@ public function __construct(
$this->tempPath = null;
}
+ public function abort(): void
+ {
+ if ($this->finalized) {
+ return;
+ }
+
+ $this->finalized = true;
+ if (is_resource($this->stream)) {
+ fclose($this->stream);
+ }
+
+ $this->stream = null;
+ $this->cleanupTempFile();
+ }
+
public function collect(string $chunk): int
{
if ($this->finalized) {
diff --git a/src/Http/Middleware/RetryMiddleware.php b/src/Http/Middleware/RetryMiddleware.php
index 8da00c1..6d8e18f 100644
--- a/src/Http/Middleware/RetryMiddleware.php
+++ b/src/Http/Middleware/RetryMiddleware.php
@@ -6,6 +6,7 @@
use Closure;
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
use Infocyph\TalkingBytes\Core\Support\RetryExecutor;
use Infocyph\TalkingBytes\Http\Contract\HttpMiddleware;
use Infocyph\TalkingBytes\Http\Enum\HttpMethod;
@@ -15,7 +16,10 @@
final readonly class RetryMiddleware implements HttpMiddleware
{
- public function __construct(private RetryPolicy $policy) {}
+ public function __construct(
+ private RetryPolicy $policy,
+ private ?CancellationSignal $cancellation = null,
+ ) {}
public function handle(HttpRequest $request, Closure $next): CommunicationResult
{
@@ -27,7 +31,11 @@ public function handle(HttpRequest $request, Closure $next): CommunicationResult
throw new InvalidArgumentException('Automatic retry requires a repeatable HTTP upload source.');
}
- return RetryExecutor::run($this->policy, static fn(): CommunicationResult => $next($request));
+ return RetryExecutor::run(
+ $this->policy,
+ static fn(): CommunicationResult => $next($request),
+ cancellation: $this->cancellation,
+ );
}
private function isRetrySafe(HttpRequest $request): bool
diff --git a/src/Http/Support/HttpRedactor.php b/src/Http/Support/HttpRedactor.php
index 6e9710c..10d2444 100644
--- a/src/Http/Support/HttpRedactor.php
+++ b/src/Http/Support/HttpRedactor.php
@@ -11,12 +11,14 @@ final class HttpRedactor
*/
private const array SENSITIVE_HEADERS = [
'authorization',
+ 'proxy-authorization',
'cookie',
'set-cookie',
'x-api-key',
'api-key',
'x-auth-token',
'x-access-token',
+ 'x-tb-signature',
];
/**
diff --git a/src/Http/Transport/CurlTransport.php b/src/Http/Transport/CurlTransport.php
index 6d33571..3ccaccd 100644
--- a/src/Http/Transport/CurlTransport.php
+++ b/src/Http/Transport/CurlTransport.php
@@ -8,6 +8,8 @@
use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer;
use Infocyph\TalkingBytes\Http\Contract\HttpTransport;
use Infocyph\TalkingBytes\Http\HttpRequest;
use Infocyph\TalkingBytes\Http\Internal\CurlHandleConfigurator;
@@ -23,17 +25,20 @@
final readonly class CurlTransport implements HttpTransport
{
+ private Clock $clock;
+
private EventDispatcher $events;
- public function __construct(?EventDispatcher $events = null)
+ public function __construct(?EventDispatcher $events = null, ?Clock $clock = null)
{
$this->events = new BestEffortEventDispatcher($events ?? new NullEventDispatcher());
+ $this->clock = $clock ?? Clock::system();
}
public function send(HttpRequest $request): CommunicationResult
{
$request = $request->prepareForTransport();
- $startedAt = microtime(true);
+ $startedAt = $this->clock->monotonic();
$this->dispatchStartEvent($request, $request->buildUrl());
$current = $request;
$visited = [];
@@ -195,7 +200,7 @@ private function dispatchResultEvents(HttpRequest $request, CommunicationResult
'url' => HttpRedactor::redactUrl($request->buildUrl()),
'status' => $result->statusCode,
'successful' => $result->successful,
- 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000),
+ 'duration_ms' => (int) (($this->clock->monotonic() - $startedAt) * 1000),
'transport' => 'curl',
];
@@ -207,7 +212,7 @@ private function dispatchResultEvents(HttpRequest $request, CommunicationResult
$this->events->dispatch('http.request.failed', [
...$payload,
- 'error' => $result->error,
+ 'failure_category' => ObservabilitySanitizer::resultContext($result)['failure_category'] ?? 'transport_error',
]);
}
diff --git a/src/Webhook/Contracts/WebhookReplayStore.php b/src/Webhook/Contracts/WebhookReplayStore.php
index b10e085..065e76a 100644
--- a/src/Webhook/Contracts/WebhookReplayStore.php
+++ b/src/Webhook/Contracts/WebhookReplayStore.php
@@ -9,7 +9,9 @@ interface WebhookReplayStore
/**
* Atomically claim a delivery identity for the TTL.
*
- * Returns true only for the first claimant.
+ * Production implementations must provide one-winner semantics across
+ * competing processes. Returns true only for the first claimant.
+ * Backend failures must throw; callers treat them as fail-closed.
*/
public function claim(string $namespace, string $deliveryId, int $ttlSeconds): bool;
}
diff --git a/src/Webhook/Model/WebhookSignature.php b/src/Webhook/Model/WebhookSignature.php
index f176a1a..097bfff 100644
--- a/src/Webhook/Model/WebhookSignature.php
+++ b/src/Webhook/Model/WebhookSignature.php
@@ -5,6 +5,7 @@
namespace Infocyph\TalkingBytes\Webhook\Model;
use Infocyph\TalkingBytes\Webhook\Signing\HmacWebhookSigner;
+use Infocyph\TalkingBytes\Webhook\Support\WebhookNameGuard;
use InvalidArgumentException;
final readonly class WebhookSignature
@@ -16,10 +17,23 @@ public function __construct(#[\SensitiveParameter] private string $secret)
}
}
- public function buildHeader(string $payload, int $timestamp): string
+ public static function deliveryPayload(string $payload, string $event, string $deliveryId): string
{
+ WebhookNameGuard::assertEvent($event);
+ WebhookNameGuard::assertDeliveryId($deliveryId);
+
+ return "talkingbytes.webhook.v2\0" . $event . "\0" . $deliveryId . "\0" . $payload;
+ }
+
+ public function buildHeader(string $payload, int $timestamp, ?string $event = null, ?string $deliveryId = null): string
+ {
+ $version = 'v1';
+ if ($event !== null || $deliveryId !== null) {
+ $payload = self::deliveryPayload($payload, $event ?? '', $deliveryId ?? '');
+ $version = 'v2';
+ }
$signature = new HmacWebhookSigner()->sign($payload, $timestamp, $this->secret);
- return sprintf('t=%d,v1=%s', $timestamp, $signature);
+ return sprintf('t=%d,%s=%s', $timestamp, $version, $signature);
}
}
diff --git a/src/Webhook/Replay/InMemoryWebhookReplayStore.php b/src/Webhook/Replay/InMemoryWebhookReplayStore.php
index baf246e..df349b8 100644
--- a/src/Webhook/Replay/InMemoryWebhookReplayStore.php
+++ b/src/Webhook/Replay/InMemoryWebhookReplayStore.php
@@ -10,6 +10,11 @@
use InvalidArgumentException;
use RuntimeException;
+/**
+ * Single-process replay store for tests and local development.
+ *
+ * It does not provide cross-process contention guarantees.
+ */
final class InMemoryWebhookReplayStore implements WebhookReplayStore
{
private readonly Clock $clock;
diff --git a/src/Webhook/Signing/WebhookSignatureParser.php b/src/Webhook/Signing/WebhookSignatureParser.php
index 24d1d10..d850a06 100644
--- a/src/Webhook/Signing/WebhookSignatureParser.php
+++ b/src/Webhook/Signing/WebhookSignatureParser.php
@@ -15,7 +15,7 @@ final class WebhookSignatureParser
/**
* @return array{timestamp:int,signatures:list}|null
*/
- public function parse(string $signatureHeader, ?string $timestampHeader = null): ?array
+ public function parse(string $signatureHeader, ?string $timestampHeader = null, string $version = 'v1'): ?array
{
if (strlen($signatureHeader) > self::MAX_HEADER_BYTES) {
return null;
@@ -33,7 +33,7 @@ public function parse(string $signatureHeader, ?string $timestampHeader = null):
$signatures = [];
foreach ($segments as $segment) {
- [$timestamp, $candidate] = $this->parseSegment($segment, $timestamp);
+ [$timestamp, $candidate] = $this->parseSegment($segment, $timestamp, $version);
if ($candidate !== null) {
$signatures[] = $candidate;
if (count($signatures) > self::MAX_SIGNATURES) {
@@ -66,7 +66,7 @@ private function parseOptionalTimestamp(?string $timestampHeader): ?int
/**
* @return array{0:?int,1:?string}
*/
- private function parseSegment(string $segment, ?int $timestamp): array
+ private function parseSegment(string $segment, ?int $timestamp, string $version): array
{
$parts = explode('=', trim($segment), 2);
if (count($parts) !== 2) {
@@ -82,7 +82,7 @@ private function parseSegment(string $segment, ?int $timestamp): array
return [$timestamp, null];
}
- if ($parts[0] !== 'v1') {
+ if ($parts[0] !== $version) {
return [$timestamp, null];
}
diff --git a/src/Webhook/Testing/WebhookTestFactory.php b/src/Webhook/Testing/WebhookTestFactory.php
index 700e0f6..045a3b4 100644
--- a/src/Webhook/Testing/WebhookTestFactory.php
+++ b/src/Webhook/Testing/WebhookTestFactory.php
@@ -26,7 +26,7 @@ public static function signedJson(
$issuedAt = $timestamp ?? time();
$delivery = $deliveryId ?? bin2hex(random_bytes(16));
WebhookNameGuard::assertDeliveryId($delivery);
- $signature = new WebhookSignature($secret)->buildHeader($rawPayload, $issuedAt);
+ $signature = new WebhookSignature($secret)->buildHeader($rawPayload, $issuedAt, $event, $delivery);
return [
$rawPayload,
diff --git a/src/Webhook/Webhook.php b/src/Webhook/Webhook.php
index 9524eb2..1ab5ee2 100644
--- a/src/Webhook/Webhook.php
+++ b/src/Webhook/Webhook.php
@@ -5,8 +5,12 @@
namespace Infocyph\TalkingBytes\Webhook;
use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Http\HttpClient;
+use Infocyph\TalkingBytes\Webhook\Contracts\WebhookReplayStore;
use Infocyph\TalkingBytes\Webhook\Testing\FakeWebhookSender;
+use InvalidArgumentException;
final readonly class Webhook
{
@@ -21,14 +25,184 @@ public static function receiver(#[\SensitiveParameter] string|array $secret, int
return new WebhookReceiver(new WebhookVerifier($secret, $maxAgeSeconds, events: $events), events: $events);
}
+ /**
+ * @param string|list $secret
+ * @param array $config
+ */
+ public static function receiverFromResolvedConfig(
+ #[\SensitiveParameter]
+ string|array $secret,
+ array $config,
+ ?WebhookReplayStore $replayStore = null,
+ ?EventDispatcher $events = null,
+ ): WebhookReceiver {
+ $receiver = new WebhookReceiver(
+ self::verifierFromResolvedConfig($secret, $config, $events),
+ maxPayloadBytes: self::int($config, 'max_payload_bytes', 1_048_576),
+ events: $events,
+ );
+
+ $replay = self::section($config, 'replay');
+ $enabled = self::bool($replay, 'enabled', $replayStore !== null);
+ if (!$enabled) {
+ return $receiver;
+ }
+
+ if (!$replayStore instanceof WebhookReplayStore) {
+ throw new InvalidArgumentException('Resolved webhook replay configuration requires a replay store.');
+ }
+
+ return $receiver->withReplayStore(
+ $replayStore,
+ self::int($replay, 'ttl_seconds', 86_400),
+ self::string($replay, 'namespace', 'default'),
+ );
+ }
+
public static function sender(HttpClient $httpClient, ?EventDispatcher $events = null): WebhookSender
{
return new WebhookSender($httpClient, events: $events);
}
+ /**
+ * @param array $config
+ */
+ public static function senderFromResolvedConfig(
+ HttpClient $httpClient,
+ array $config,
+ ?EventDispatcher $events = null,
+ ?Clock $clock = null,
+ ?Sleeper $sleeper = null,
+ ): WebhookSender {
+ $sender = new WebhookSender(
+ $httpClient,
+ maxPayloadBytes: self::int($config, 'max_payload_bytes', 1_048_576),
+ events: $events,
+ clock: $clock,
+ sleeper: $sleeper,
+ );
+
+ $secret = $config['signing_secret'] ?? null;
+ if ($secret !== null) {
+ if (!is_string($secret) || trim($secret) === '') {
+ throw new InvalidArgumentException('Resolved webhook signing_secret must be a non-empty string.');
+ }
+ $sender = $sender->withSecret($secret);
+ }
+
+ $retry = self::section($config, 'retry');
+ if (self::bool($retry, 'enabled', false)) {
+ $sender = $sender->withRetryProfile(
+ self::int($retry, 'attempts', 3),
+ self::int($retry, 'base_delay_ms', 250),
+ self::int($retry, 'max_retry_after_seconds', 30),
+ );
+ }
+
+ return $sender;
+ }
+
/** @param string|list $secret */
public static function verifier(#[\SensitiveParameter] string|array $secret, int $maxAgeSeconds = 300, ?EventDispatcher $events = null): WebhookVerifier
{
return new WebhookVerifier($secret, $maxAgeSeconds, events: $events);
}
+
+ /**
+ * @param string|list $secret
+ * @param array $config
+ */
+ public static function verifierFromResolvedConfig(
+ #[\SensitiveParameter]
+ string|array $secret,
+ array $config,
+ ?EventDispatcher $events = null,
+ ): WebhookVerifier {
+ return self::verifier(
+ $secret,
+ self::int($config, 'max_age_seconds', 300),
+ $events,
+ );
+ }
+
+ /** @param array $config */
+ private static function bool(array $config, string $key, bool $default): bool
+ {
+ if (!array_key_exists($key, $config)) {
+ return $default;
+ }
+
+ $value = $config[$key];
+ if (is_bool($value)) {
+ return $value;
+ }
+
+ if (is_int($value) && ($value === 0 || $value === 1)) {
+ return $value === 1;
+ }
+
+ if (is_string($value)) {
+ $parsed = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
+ if (is_bool($parsed)) {
+ return $parsed;
+ }
+ }
+
+ throw new InvalidArgumentException(sprintf('Webhook resolved configuration key "%s" must be a boolean.', $key));
+ }
+
+ /** @param array $config */
+ private static function int(array $config, string $key, int $default): int
+ {
+ $value = $config[$key] ?? $default;
+ if (is_int($value)) {
+ return $value;
+ }
+
+ if (is_string($value) && preg_match('/^-?\d+$/D', $value) === 1) {
+ $parsed = filter_var($value, FILTER_VALIDATE_INT);
+ if (is_int($parsed)) {
+ return $parsed;
+ }
+ }
+
+ throw new InvalidArgumentException(sprintf('Webhook resolved configuration key "%s" must be an integer.', $key));
+ }
+
+ /**
+ * @param array $config
+ * @return array
+ */
+ private static function section(array $config, string $key): array
+ {
+ $value = $config[$key] ?? [];
+ if (!is_array($value)) {
+ throw new InvalidArgumentException(sprintf('Webhook resolved configuration section "%s" must be an array.', $key));
+ }
+
+ $section = [];
+ foreach ($value as $name => $item) {
+ if (is_string($name)) {
+ $section[$name] = $item;
+ }
+ }
+
+ return $section;
+ }
+
+ /** @param array $config */
+ private static function string(array $config, string $key, string $default): string
+ {
+ $value = $config[$key] ?? $default;
+ if (!is_string($value)) {
+ throw new InvalidArgumentException(sprintf('Webhook resolved configuration key "%s" must be a string.', $key));
+ }
+
+ $value = trim($value);
+ if ($value === '') {
+ throw new InvalidArgumentException(sprintf('Webhook resolved configuration key "%s" must be non-empty.', $key));
+ }
+
+ return $value;
+ }
}
diff --git a/src/Webhook/WebhookReceiver.php b/src/Webhook/WebhookReceiver.php
index 83bc986..a19d6c6 100644
--- a/src/Webhook/WebhookReceiver.php
+++ b/src/Webhook/WebhookReceiver.php
@@ -43,10 +43,23 @@ public function receive(string $rawBody, array $headers): WebhookEvent
throw new InvalidArgumentException(sprintf('Webhook payload exceeded %d bytes.', $this->maxPayloadBytes));
}
+ $event = $this->header($headers, WebhookHeaders::EVENT);
+ $deliveryId = $this->header($headers, WebhookHeaders::DELIVERY);
+ if ($event === null) {
+ throw new InvalidArgumentException('Webhook event header is missing.');
+ }
+
+ if ($deliveryId === null) {
+ throw new InvalidArgumentException('Webhook delivery header is missing.');
+ }
+
+ WebhookNameGuard::assertEvent($event);
+ WebhookNameGuard::assertDeliveryId($deliveryId);
+
$signatureHeader = $this->header($headers, WebhookHeaders::SIGNATURE) ?? '';
$timestampHeader = $this->header($headers, WebhookHeaders::TIMESTAMP);
- $verification = $this->verifier->verifyResult($rawBody, $signatureHeader, $timestampHeader);
+ $verification = $this->verifier->verifyResult($rawBody, $signatureHeader, $timestampHeader, event: $event, deliveryId: $deliveryId);
if (!$verification->valid) {
throw new RuntimeException(sprintf('Webhook verification failed: %s', (string) $verification->reason));
}
@@ -61,19 +74,6 @@ public function receive(string $rawBody, array $headers): WebhookEvent
throw new InvalidArgumentException('Webhook payload must decode to an object/array JSON value.');
}
- $event = $this->header($headers, WebhookHeaders::EVENT);
- $deliveryId = $this->header($headers, WebhookHeaders::DELIVERY);
- if ($event === null) {
- throw new InvalidArgumentException('Webhook event header is missing.');
- }
-
- if ($deliveryId === null) {
- throw new InvalidArgumentException('Webhook delivery header is missing.');
- }
-
- WebhookNameGuard::assertEvent($event);
- WebhookNameGuard::assertDeliveryId($deliveryId);
-
if ($this->replayStore !== null) {
if (!$this->replayStore->claim($this->replayNamespace, $deliveryId, $this->replayTtlSeconds)) {
throw new RuntimeException(sprintf('Webhook delivery "%s" has already been processed.', $deliveryId));
diff --git a/src/Webhook/WebhookSender.php b/src/Webhook/WebhookSender.php
index 76afcbe..b3954b8 100644
--- a/src/Webhook/WebhookSender.php
+++ b/src/Webhook/WebhookSender.php
@@ -7,7 +7,10 @@
use Infocyph\TalkingBytes\Core\Event\BestEffortEventDispatcher;
use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
+use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer;
use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Http\HttpClient;
use Infocyph\TalkingBytes\Http\HttpRequest;
@@ -15,6 +18,7 @@
use Infocyph\TalkingBytes\Retry\RetryContext;
use Infocyph\TalkingBytes\Webhook\Model\WebhookDelivery;
use Infocyph\TalkingBytes\Webhook\Model\WebhookDeliveryResult;
+use Infocyph\TalkingBytes\Webhook\Model\WebhookSignature;
use Infocyph\TalkingBytes\Webhook\Retry\WebhookRetryProfile;
use Infocyph\TalkingBytes\Webhook\Signing\HmacWebhookSigner;
use Infocyph\TalkingBytes\Webhook\Signing\WebhookSigner;
@@ -42,6 +46,7 @@ public function __construct(
?EventDispatcher $events = null,
?Clock $clock = null,
?Sleeper $sleeper = null,
+ private ?CancellationSignal $cancellation = null,
) {
if ($this->maxPayloadBytes < 1) {
throw new InvalidArgumentException('Webhook max payload bytes must be greater than zero.');
@@ -69,10 +74,20 @@ public static function usingHttpWithRetryProfile(
?EventDispatcher $events = null,
?Clock $clock = null,
?Sleeper $sleeper = null,
+ ?CancellationSignal $cancellation = null,
): self {
$profile = WebhookRetryProfile::standard($attempts, $baseDelayMs, $maxRetryAfterSeconds);
- return new self($httpClient, null, null, $profile, events: $events, clock: $clock, sleeper: $sleeper);
+ return new self(
+ $httpClient,
+ null,
+ null,
+ $profile,
+ events: $events,
+ clock: $clock,
+ sleeper: $sleeper,
+ cancellation: $cancellation,
+ );
}
public function send(WebhookMessage $webhook): WebhookDelivery
@@ -87,11 +102,21 @@ public function send(WebhookMessage $webhook): WebhookDelivery
'url' => $redactedUrl,
'attempt' => 1,
]);
- $startedAt = microtime(true);
+ $startedAt = $this->clock->monotonic();
$attempt = 1;
+ $completedAttempts = 0;
+ $cancelled = false;
$retryPolicy = $this->retryProfile?->toHttpRetryPolicy();
+ $result = null;
while (true) {
+ if ($this->cancellation?->isRequested() === true) {
+ $cancelled = true;
+ $result = $this->cancelledResult($result, $completedAttempts);
+
+ break;
+ }
+
$timestamp = (int) floor($this->clock->timestamp());
$request = HttpRequest::post($url)
->raw($payload, 'application/json')
@@ -103,11 +128,12 @@ public function send(WebhookMessage $webhook): WebhookDelivery
->header(WebhookHeaders::CONTENT_TYPE, 'application/json');
if ($this->signingSecret !== null) {
- $signature = $this->signature($payload, $timestamp);
- $request = $request->header(WebhookHeaders::SIGNATURE, sprintf('t=%d,v1=%s', $timestamp, $signature));
+ $signature = $this->signature(WebhookSignature::deliveryPayload($payload, $webhook->event, $webhook->deliveryId), $timestamp);
+ $request = $request->header(WebhookHeaders::SIGNATURE, sprintf('t=%d,v2=%s', $timestamp, $signature));
}
$result = $this->httpClient->send($request);
+ $completedAttempts = $attempt;
$decision = $retryPolicy?->decide(new RetryContext($attempt, $result));
if ($decision === null || !$decision->retry) {
@@ -120,12 +146,19 @@ public function send(WebhookMessage $webhook): WebhookDelivery
'url' => $redactedUrl,
'attempt' => $attempt + 1,
'status_code' => $result->statusCode,
- 'error' => $result->error,
+ 'failure_category' => ObservabilitySanitizer::resultContext($result)['failure_category'] ?? 'transport_error',
]);
$delayMs = $decision->delayMs;
if ($delayMs > 0) {
- $this->sleeper->milliseconds($delayMs);
+ if ($this->cancellation === null) {
+ $this->sleeper->milliseconds($delayMs);
+ } elseif (!$this->sleeper->millisecondsInterruptibly($delayMs, $this->cancellation)) {
+ $cancelled = true;
+ $result = $this->cancelledResult($result, $completedAttempts);
+
+ break;
+ }
}
$attempt++;
@@ -134,13 +167,14 @@ public function send(WebhookMessage $webhook): WebhookDelivery
deliveryId: $webhook->deliveryId,
event: $webhook->event,
url: $url,
- attempts: $attempt,
+ attempts: $completedAttempts,
delivered: $result->successful,
statusCode: $result->statusCode,
error: $result->error,
metadata: [
- 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000),
+ 'duration_ms' => (int) (($this->clock->monotonic() - $startedAt) * 1000),
'has_signature' => $this->signingSecret !== null,
+ 'cancelled' => $cancelled,
],
);
@@ -151,9 +185,12 @@ public function send(WebhookMessage $webhook): WebhookDelivery
'delivery_id' => $webhook->deliveryId,
'url' => $redactedUrl,
'status_code' => $result->statusCode,
- 'error' => $result->error,
+ 'failure_category' => $result->successful
+ ? null
+ : (ObservabilitySanitizer::resultContext($result)['failure_category'] ?? 'transport_error'),
'duration_ms' => $delivery->metadata['duration_ms'] ?? null,
- 'attempt' => $attempt,
+ 'attempt' => $completedAttempts,
+ 'cancelled' => $cancelled,
],
);
@@ -166,7 +203,22 @@ public function signingSecret(#[\SensitiveParameter] string $secret): self
throw new InvalidArgumentException('Webhook signing secret must not be empty.');
}
- return new self($this->httpClient, $secret, $this->signer, $this->retryProfile, $this->maxPayloadBytes, $this->events, $this->clock, $this->sleeper);
+ return new self($this->httpClient, $secret, $this->signer, $this->retryProfile, $this->maxPayloadBytes, $this->events, $this->clock, $this->sleeper, $this->cancellation);
+ }
+
+ public function withCancellation(?CancellationSignal $cancellation): self
+ {
+ return new self(
+ $this->httpClient,
+ $this->signingSecret,
+ $this->signer,
+ $this->retryProfile,
+ $this->maxPayloadBytes,
+ $this->events,
+ $this->clock,
+ $this->sleeper,
+ $cancellation,
+ );
}
public function withRetryProfile(
@@ -176,7 +228,7 @@ public function withRetryProfile(
): self {
$profile = WebhookRetryProfile::standard($attempts, $baseDelayMs, $maxRetryAfterSeconds);
- return new self($this->httpClient, $this->signingSecret, $this->signer, $profile, $this->maxPayloadBytes, $this->events, $this->clock, $this->sleeper);
+ return new self($this->httpClient, $this->signingSecret, $this->signer, $profile, $this->maxPayloadBytes, $this->events, $this->clock, $this->sleeper, $this->cancellation);
}
public function withSecret(#[\SensitiveParameter] string $secret): self
@@ -186,7 +238,23 @@ public function withSecret(#[\SensitiveParameter] string $secret): self
public function withSigner(WebhookSigner $signer): self
{
- return new self($this->httpClient, $this->signingSecret, $signer, $this->retryProfile, $this->maxPayloadBytes, $this->events, $this->clock, $this->sleeper);
+ return new self($this->httpClient, $this->signingSecret, $signer, $this->retryProfile, $this->maxPayloadBytes, $this->events, $this->clock, $this->sleeper, $this->cancellation);
+ }
+
+ private function cancelledResult(?CommunicationResult $previous, int $attempts): CommunicationResult
+ {
+ $metadata = $previous === null ? [] : $previous->metadata;
+
+ return CommunicationResult::failure(
+ 'Webhook delivery cancelled.',
+ $previous?->statusCode,
+ $previous?->response,
+ [
+ ...$metadata,
+ 'cancelled' => true,
+ 'attempts' => $attempts,
+ ],
+ );
}
private function signature(string $payload, int $timestamp): string
diff --git a/src/Webhook/WebhookVerifier.php b/src/Webhook/WebhookVerifier.php
index 4b2174e..0509344 100644
--- a/src/Webhook/WebhookVerifier.php
+++ b/src/Webhook/WebhookVerifier.php
@@ -8,6 +8,7 @@
use Infocyph\TalkingBytes\Core\Event\EventDispatcher;
use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher;
use Infocyph\TalkingBytes\Core\Support\Clock;
+use Infocyph\TalkingBytes\Webhook\Model\WebhookSignature;
use Infocyph\TalkingBytes\Webhook\Model\WebhookVerificationResult;
use Infocyph\TalkingBytes\Webhook\Signing\HmacWebhookSigner;
use Infocyph\TalkingBytes\Webhook\Signing\WebhookSignatureParser;
@@ -69,6 +70,8 @@ public function verifyResult(
string $signatureHeader,
?string $timestampHeader = null,
?int $now = null,
+ ?string $event = null,
+ ?string $deliveryId = null,
): WebhookVerificationResult {
$now ??= (int) floor($this->clock->timestamp());
@@ -84,7 +87,12 @@ public function verifyResult(
return $this->reject('missing_timestamp');
}
- $parsed = $this->signatureParser->parse($signatureHeader, $timestampHeader);
+ $version = 'v1';
+ if ($event !== null || $deliveryId !== null) {
+ $payload = WebhookSignature::deliveryPayload($payload, $event ?? '', $deliveryId ?? '');
+ $version = 'v2';
+ }
+ $parsed = $this->signatureParser->parse($signatureHeader, $timestampHeader, $version);
if ($parsed === null) {
if ($timestampHeader !== null && trim($timestampHeader) !== '' && !ctype_digit(trim($timestampHeader))) {
return $this->reject('invalid_timestamp');
diff --git a/tests/BounceParserTest.php b/tests/BounceParserTest.php
index 9a47d3f..cc0d2dc 100644
--- a/tests/BounceParserTest.php
+++ b/tests/BounceParserTest.php
@@ -2,8 +2,8 @@
declare(strict_types=1);
+use Infocyph\TalkingBytes\Core\Event\CallableEventDispatcher;
use Infocyph\TalkingBytes\Email\Enum\BounceType;
-use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus as EmailEventBus;
use Infocyph\TalkingBytes\Email\Parser\BounceParser;
use Infocyph\TalkingBytes\Email\Parser\DeliveryStatusParser;
use Infocyph\TalkingBytes\Email\Parser\RawEmailParser;
@@ -82,10 +82,6 @@
it('dispatches bounce.detected event when bounce is parsed', function (): void {
$events = [];
- EmailEventBus::listen(static function (string $event, array $payload) use (&$events): void {
- $events[] = ['event' => $event, 'payload' => $payload];
- });
-
$raw = implode("\r\n", [
'From: postmaster@example.com',
'To: sender@example.com',
@@ -96,7 +92,10 @@
]);
$email = (new RawEmailParser)->parse($raw);
- $report = (new BounceParser)->parse($email);
+ $dispatcher = new CallableEventDispatcher(static function (string $event, array $payload) use (&$events): void {
+ $events[] = ['event' => $event, 'payload' => $payload];
+ });
+ $report = (new BounceParser(events: $dispatcher))->parse($email);
expect($report)->not->toBeNull();
expect($events)->not->toBeEmpty();
diff --git a/tests/CharsetDecoderTest.php b/tests/CharsetDecoderTest.php
index bc172e2..cf81b09 100644
--- a/tests/CharsetDecoderTest.php
+++ b/tests/CharsetDecoderTest.php
@@ -5,11 +5,12 @@
use Infocyph\TalkingBytes\Email\Parser\CharsetDecoder;
it('decodes common charset aliases to utf8', function (): void {
+ $decoder = new CharsetDecoder;
if (! function_exists('iconv') && ! function_exists('mb_convert_encoding')) {
- $this->markTestSkipped('charset conversion extensions are unavailable.');
- }
+ expect($decoder->toUtf8('plain', 'ISO-8859-1'))->toBe('plain');
- $decoder = new CharsetDecoder;
+ return;
+ }
$cases = [
['charset' => 'ISO-8859-1', 'text' => 'Café'],
@@ -37,28 +38,32 @@
it('uses configured fallback charset when source charset is unknown', function (): void {
if (! function_exists('iconv') && ! function_exists('mb_convert_encoding')) {
- $this->markTestSkipped('charset conversion extensions are unavailable.');
+ expect((new CharsetDecoder('WINDOWS-1252'))->toUtf8('plain', 'X-UNKNOWN-CHARSET'))->toBe('plain');
+
+ return;
}
$source = function_exists('iconv')
? iconv('UTF-8', 'WINDOWS-1252//IGNORE', 'Résumé')
: mb_convert_encoding('Résumé', 'WINDOWS-1252', 'UTF-8');
+ $decoder = new CharsetDecoder('WINDOWS-1252');
if (! is_string($source) || $source === '') {
- $this->markTestSkipped('Unable to prepare fallback charset test payload.');
- }
+ expect($decoder->toUtf8('plain', 'X-UNKNOWN-CHARSET'))->toBe('plain');
- $decoder = new CharsetDecoder('WINDOWS-1252');
+ return;
+ }
expect($decoder->toUtf8($source, 'X-UNKNOWN-CHARSET'))->toBe('Résumé');
});
it('supports additional charset aliases and handles invalid byte payloads safely', function (): void {
+ $decoder = new CharsetDecoder('WINDOWS-1252');
if (! function_exists('iconv') && ! function_exists('mb_convert_encoding')) {
- $this->markTestSkipped('charset conversion extensions are unavailable.');
- }
+ expect($decoder->toUtf8("\xFF\xFE\xFA", 'X-UNKNOWN'))->toBeString();
- $decoder = new CharsetDecoder('WINDOWS-1252');
+ return;
+ }
$cases = [
['charset' => 'CP850', 'text' => 'Cafe'],
['charset' => 'GB18030', 'text' => '中文'],
@@ -84,3 +89,37 @@
$decoded = $decoder->toUtf8($invalidBytes, 'X-UNKNOWN');
expect($decoded)->toBeString();
});
+
+it('restores the error handler stack and original error mask after charset fallback', function (): void {
+ $outer = static fn(): bool => true;
+ $calls = [];
+ $inner = static function (int $severity) use (&$calls): bool {
+ $calls[] = $severity;
+ return true;
+ };
+ $reporting = error_reporting(E_USER_WARNING);
+ set_error_handler($outer);
+ set_error_handler($inner, E_USER_WARNING);
+ try {
+ for ($i = 0; $i < 3; $i++) {
+ (new CharsetDecoder())->toUtf8('text', 'INVALID-CHARSET-AUDIT');
+ }
+ trigger_error('excluded notice', E_USER_NOTICE);
+ trigger_error('included warning', E_USER_WARNING);
+ restore_error_handler();
+ $after = set_error_handler($outer);
+ restore_error_handler();
+ } finally {
+ error_reporting($reporting);
+ // Unwind even a broken implementation so the regression cannot pollute other tests.
+ for ($i = 0; $i < 20; $i++) {
+ $current = set_error_handler($outer);
+ restore_error_handler();
+ restore_error_handler();
+ if ($current === $outer) {
+ break;
+ }
+ }
+ }
+ expect($after)->toBe($outer)->and($calls)->toBe([E_USER_WARNING]);
+});
diff --git a/tests/EmailOutboundExtensionsTest.php b/tests/EmailOutboundExtensionsTest.php
index ad0bfd0..3f11e80 100644
--- a/tests/EmailOutboundExtensionsTest.php
+++ b/tests/EmailOutboundExtensionsTest.php
@@ -164,7 +164,9 @@ public function send(EmailMessage $message): CommunicationResult
expect($events[0]['event'])->toBe('email.send.start');
expect($events[1]['event'])->toBe('email.send.finish');
expect($events[1]['context']['successful'])->toBeFalse();
- expect($events[1]['context']['error'])->toBe('transport boom');
+ expect($events[1]['context']['failure_category'] ?? null)->toBe('exception');
+ expect($events[1]['context']['exception_class'] ?? null)->toBe(RuntimeException::class);
+ expect(json_encode($events, JSON_THROW_ON_ERROR))->not->toContain('transport boom');
});
it('fails clearly when log transport directory path is not a directory', function (): void {
diff --git a/tests/Fixtures/concurrent-http-server.php b/tests/Fixtures/concurrent-http-server.php
new file mode 100644
index 0000000..9ebb8e1
--- /dev/null
+++ b/tests/Fixtures/concurrent-http-server.php
@@ -0,0 +1,133 @@
+ $delayMs) {
+ if (is_string($path) && is_int($delayMs)) {
+ $delays[$path] = $delayMs;
+ }
+}
+
+$server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
+if ($server === false) {
+ file_put_contents($reportPath, json_encode(['error' => sprintf('%s (%d)', $errstr, $errno)]));
+
+ throw new RuntimeException('Unable to bind concurrent HTTP fixture server.');
+}
+
+stream_set_blocking($server, false);
+$address = stream_socket_get_name($server, false);
+if (!is_string($address) || !str_contains($address, ':')) {
+ fclose($server);
+
+ throw new RuntimeException('Unable to resolve concurrent HTTP fixture server address.');
+}
+
+$port = (int) substr(strrchr($address, ':'), 1);
+file_put_contents($readyPath, json_encode(['port' => $port], JSON_THROW_ON_ERROR));
+
+$startedAt = microtime(true);
+$deadline = $startedAt + 5.0;
+$expected = count($delays);
+$completed = 0;
+$requestTimes = [];
+
+/**
+ * @var array $clients
+ */
+$clients = [];
+
+while ($completed < $expected && microtime(true) < $deadline) {
+ $read = [$server];
+ foreach ($clients as $client) {
+ if ($client['path'] === null) {
+ $read[] = $client['stream'];
+ }
+ }
+
+ $write = [];
+ $except = [];
+ stream_select($read, $write, $except, 0, 10_000);
+
+ foreach ($read as $stream) {
+ if ($stream === $server) {
+ while (($client = stream_socket_accept($server, 0)) !== false) {
+ stream_set_blocking($client, false);
+ $clients[(int) $client] = [
+ 'stream' => $client,
+ 'buffer' => '',
+ 'path' => null,
+ 'due' => null,
+ ];
+ }
+
+ continue;
+ }
+
+ $id = (int) $stream;
+ if (!isset($clients[$id])) {
+ continue;
+ }
+
+ $chunk = fread($stream, 8192);
+ if (!is_string($chunk) || $chunk === '') {
+ continue;
+ }
+
+ $clients[$id]['buffer'] .= $chunk;
+ if ($clients[$id]['path'] !== null || !str_contains($clients[$id]['buffer'], "\r\n\r\n")) {
+ continue;
+ }
+
+ if (preg_match('/^[A-Z]+\\s+(\\S+)\\s+HTTP\\/\\d(?:\\.\\d)?/D', $clients[$id]['buffer'], $matches) !== 1) {
+ continue;
+ }
+
+ $path = parse_url($matches[1], PHP_URL_PATH);
+ $path = is_string($path) && $path !== '' ? $path : '/';
+ $delayMs = $delays[$path] ?? 0;
+
+ $clients[$id]['path'] = $path;
+ $clients[$id]['due'] = microtime(true) + ($delayMs / 1000);
+ $requestTimes[$path] = (int) round((microtime(true) - $startedAt) * 1000);
+ }
+
+ $now = microtime(true);
+ foreach ($clients as $id => $client) {
+ if ($client['due'] === null || $client['due'] > $now) {
+ continue;
+ }
+
+ $body = $client['path'] ?? '/';
+ $response = "HTTP/1.1 200 OK\r\n"
+ . 'Content-Type: text/plain' . "\r\n"
+ . 'Content-Length: ' . strlen($body) . "\r\n"
+ . 'Connection: close' . "\r\n\r\n"
+ . $body;
+
+ fwrite($client['stream'], $response);
+ fclose($client['stream']);
+ unset($clients[$id]);
+ $completed++;
+ }
+}
+
+foreach ($clients as $client) {
+ fclose($client['stream']);
+}
+
+fclose($server);
+file_put_contents($reportPath, json_encode([
+ 'request_times' => $requestTimes,
+ 'completed' => $completed,
+], JSON_THROW_ON_ERROR));
diff --git a/tests/GrpcGeneratedStubInvokerTest.php b/tests/GrpcGeneratedStubInvokerTest.php
index 930e0e4..dadc7ee 100644
--- a/tests/GrpcGeneratedStubInvokerTest.php
+++ b/tests/GrpcGeneratedStubInvokerTest.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
use Infocyph\TalkingBytes\Grpc\GrpcClient;
use Infocyph\TalkingBytes\Grpc\GrpcMetadata;
use Infocyph\TalkingBytes\Grpc\Sender\GrpcRequest;
@@ -177,6 +178,7 @@ static function (mixed $message) use (&$serverChunks): void {
expect($unary->successful)->toBeTrue()
->and($unary->response)->toBeInstanceOf(GrpcResponse::class)
->and($unary->response->message['id'])->toBe(1001)
+ ->and($unary->response->trailers->first('x-trailer'))->toBe('create-end')
->and($server->successful)->toBeTrue()
->and($serverChunks)->toHaveCount(2)
->and($clientOnly->successful)->toBeTrue()
@@ -218,3 +220,194 @@ public function wait(): array
expect($result->successful)->toBeTrue()
->and($result->response->message['ok'])->toBeTrue();
});
+
+
+it('does not reinvoke a stream method when the stub throws an internal type error', function (): void {
+ $stub = new class {
+ public int $calls = 0;
+
+ public function Upload(array $metadata = [], array $options = []): object
+ {
+ unset($metadata, $options);
+ $this->calls++;
+
+ throw new TypeError('internal generated stub type error');
+ }
+ };
+
+ $client = GrpcClient::usingGeneratedStub($stub);
+ $result = $client->clientStream(
+ method: 'Orders/Upload',
+ messages: [['id' => 1]],
+ );
+
+ expect($result->successful)->toBeFalse();
+ expect($stub->calls)->toBe(1);
+ expect($result->error)->toContain('internal generated stub type error');
+});
+
+it('resolves three argument stream open shape before invocation', function (): void {
+ $stub = new class {
+ /** @var list> */
+ public array $captures = [];
+
+ public function Upload(mixed $message = null, array $metadata = [], array $options = []): object
+ {
+ $this->captures[] = [
+ 'message' => $message,
+ 'metadata' => $metadata,
+ 'options' => $options,
+ ];
+
+ return new class {
+ public function wait(): array
+ {
+ return [['ok' => true], ['code' => 0]];
+ }
+
+ public function writesDone(): void {}
+
+ public function write(mixed $message): void
+ {
+ unset($message);
+ }
+ };
+ }
+ };
+
+ $result = GrpcClient::usingGeneratedStub($stub)->clientStream(
+ method: 'Orders/Upload',
+ messages: [['id' => 1]],
+ headers: (new GrpcMetadata())->withValue('x-upload', 'yes'),
+ deadlineSeconds: 1.0,
+ );
+
+ expect($result->successful)->toBeTrue();
+ expect($stub->captures)->toHaveCount(1);
+ expect($stub->captures[0]['message'])->toBeNull();
+ expect($stub->captures[0]['metadata']['x-upload'][0] ?? null)->toBe('yes');
+ expect($stub->captures[0]['options']['timeout'] ?? null)->toBe(1_000_000);
+});
+
+it('validates explicit generated method maps at adapter construction', function (): void {
+ $stub = new class {
+ public function Create(mixed $message, array $metadata = [], array $options = []): object
+ {
+ unset($message, $metadata, $options);
+
+ return new class {
+ public function wait(): array
+ {
+ return [['ok' => true], ['code' => 0]];
+ }
+ };
+ }
+ };
+
+ expect(fn() => new GeneratedStubGrpcInvoker($stub, [
+ '/orders.v1.OrderService/Create' => 'Missing',
+ ]))->toThrow(InvalidArgumentException::class, 'was not found');
+
+ expect(fn() => new GeneratedStubGrpcInvoker($stub, [
+ ' orders.v1.OrderService/Create' => 'Create',
+ ]))->toThrow(InvalidArgumentException::class, 'surrounding whitespace');
+});
+
+it('cancels generated client streams between outbound writes', function (): void {
+ $state = (object) ['requested' => false];
+
+ $stub = new class($state) {
+ public ?object $call = null;
+
+ public function __construct(private object $state) {}
+
+ public function Upload(array $metadata = [], array $options = []): object
+ {
+ unset($metadata, $options);
+ $state = $this->state;
+
+ return $this->call = new class($state) {
+ public bool $cancelled = false;
+
+ public int $writes = 0;
+
+ public function __construct(private object $state) {}
+
+ public function cancel(): void
+ {
+ $this->cancelled = true;
+ }
+
+ public function wait(): array
+ {
+ return [['ok' => true], ['code' => 0]];
+ }
+
+ public function writesDone(): void {}
+
+ public function write(mixed $message): void
+ {
+ unset($message);
+ $this->writes++;
+ $this->state->requested = true;
+ }
+ };
+ }
+ };
+
+ $signal = CancellationSignal::fromCallable(static fn(): bool => $state->requested);
+ $result = GrpcClient::usingGeneratedStub($stub, cancellation: $signal)->clientStream(
+ method: 'Orders/Upload',
+ messages: [['id' => 1], ['id' => 2]],
+ );
+
+ expect($result->successful)->toBeFalse();
+ expect($stub->call)->not->toBeNull();
+ expect($stub->call?->writes)->toBe(1);
+ expect($stub->call?->cancelled)->toBeTrue();
+ expect($result->error)->toContain('cancelled');
+});
+
+it('cancels the native stream when a response callback fails', function (): void {
+ $stub = new class {
+ public ?object $call = null;
+
+ public function List(mixed $message, array $metadata = [], array $options = []): object
+ {
+ unset($message, $metadata, $options);
+
+ return $this->call = new class {
+ public bool $cancelled = false;
+
+ public function cancel(): void
+ {
+ $this->cancelled = true;
+ }
+
+ public function responses(): iterable
+ {
+ yield ['row' => 1];
+ yield ['row' => 2];
+ }
+
+ public function wait(): array
+ {
+ return [null, ['code' => 0]];
+ }
+ };
+ }
+ };
+
+ $result = GrpcClient::usingGeneratedStub($stub)->serverStream(
+ new GrpcRequest('Orders/List', ['page' => 1]),
+ static function (mixed $message): void {
+ unset($message);
+
+ throw new RuntimeException('consumer callback failed');
+ },
+ );
+
+ expect($result->successful)->toBeFalse();
+ expect($stub->call?->cancelled)->toBeTrue();
+ expect($result->error)->toContain('consumer callback failed');
+});
diff --git a/tests/GrpcServerTest.php b/tests/GrpcServerTest.php
index 5ae6958..22ddc6a 100644
--- a/tests/GrpcServerTest.php
+++ b/tests/GrpcServerTest.php
@@ -2,14 +2,18 @@
declare(strict_types=1);
-use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus;
use Infocyph\TalkingBytes\Core\Event\CallableEventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
+use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundExchange;
use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundHandlerInterface;
use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundRequest;
use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundResponse;
+use Infocyph\TalkingBytes\Grpc\Receiver\GrpcInboundSource;
use Infocyph\TalkingBytes\Grpc\GrpcMetadata;
use Infocyph\TalkingBytes\Grpc\GrpcInboundDispatcher;
use Infocyph\TalkingBytes\Grpc\GrpcStatus;
+use Infocyph\TalkingBytes\Grpc\Testing\FakeGrpcInboundExchange;
+use Infocyph\TalkingBytes\Grpc\Testing\FakeGrpcInboundSource;
it('handles inbound grpc requests with registered handlers', function (): void {
$server = GrpcInboundDispatcher::new()->withHandler(
@@ -81,10 +85,14 @@ static function (): GrpcInboundResponse {
$response = $server->receive('/orders.v1.OrderService/Create', ['order_id' => 1]);
expect($response->status)->toBe(GrpcStatus::Internal)
- ->and($response->metadata['exception'] ?? null)->toBe(RuntimeException::class)
+ ->and($response->message)->toBe('Inbound gRPC handler failed.')
+ ->and($response->metadata)->toBe([])
->and($events)->toHaveCount(2)
->and($events[0][0])->toBe('grpc.inbound.start')
- ->and($events[1][0])->toBe('grpc.inbound.failed');
+ ->and($events[1][0])->toBe('grpc.inbound.failed')
+ ->and($events[1][1]['exception'] ?? null)->toBe(RuntimeException::class)
+ ->and(json_encode($response, JSON_THROW_ON_ERROR))->not->toContain('handler exploded')
+ ->and(json_encode($response, JSON_THROW_ON_ERROR))->not->toContain(RuntimeException::class);
});
it('validates inbound grpc request method and deadline', function (): void {
@@ -92,3 +100,79 @@ static function (): GrpcInboundResponse {
expect(fn() => new GrpcInboundRequest('Service', ['x' => 1]))->toThrow(InvalidArgumentException::class);
expect(fn() => new GrpcInboundRequest('/Svc/Call', ['x' => 1], deadlineSeconds: 0.0))->toThrow(InvalidArgumentException::class);
});
+
+
+it('serves one inbound grpc exchange through a host-controlled source', function (): void {
+ $source = new FakeGrpcInboundSource();
+ $exchange = $source->enqueue(new GrpcInboundRequest(
+ '/orders.v1.OrderService/Create',
+ ['order_id' => 44],
+ ));
+ $server = GrpcInboundDispatcher::new()->withHandler(
+ '/orders.v1.OrderService/Create',
+ static fn(GrpcInboundRequest $request): GrpcInboundResponse => GrpcInboundResponse::ok([
+ 'order_id' => $request->message['order_id'] ?? null,
+ 'accepted' => true,
+ ]),
+ );
+
+ $served = $server->serveOne($source);
+
+ expect($served)->toBeTrue()
+ ->and($source->pendingCount())->toBe(0)
+ ->and($source->accepted())->toHaveCount(1)
+ ->and($exchange->completed())->toBeTrue()
+ ->and($exchange->response()?->status)->toBe(GrpcStatus::Ok)
+ ->and($exchange->response()?->message['order_id'] ?? null)->toBe(44);
+});
+
+it('does not accept another inbound grpc exchange after host cancellation', function (): void {
+ $source = new FakeGrpcInboundSource();
+ $source->enqueue(new GrpcInboundRequest('/orders.v1.OrderService/Create', ['order_id' => 45]));
+
+ $served = GrpcInboundDispatcher::new()->serveOne(
+ $source,
+ CancellationSignal::fromCallable(static fn(): bool => true),
+ );
+
+ expect($served)->toBeFalse()
+ ->and($source->pendingCount())->toBe(1)
+ ->and($source->accepted())->toBe([]);
+});
+
+it('completes an already accepted grpc exchange as cancelled when host cancellation arrives', function (): void {
+ $exchange = new FakeGrpcInboundExchange(
+ new GrpcInboundRequest('/orders.v1.OrderService/Create', ['order_id' => 46]),
+ );
+ $source = new class($exchange) implements GrpcInboundSource {
+ public function __construct(private readonly GrpcInboundExchange $exchange) {}
+
+ public function accept(?CancellationSignal $cancellation = null): ?GrpcInboundExchange
+ {
+ unset($cancellation);
+
+ return $this->exchange;
+ }
+ };
+ $checks = 0;
+ $cancellation = CancellationSignal::fromCallable(static function () use (&$checks): bool {
+ $checks++;
+
+ return $checks > 1;
+ });
+
+ $served = GrpcInboundDispatcher::new()->serveOne($source, $cancellation);
+
+ expect($served)->toBeTrue()
+ ->and($exchange->completed())->toBeTrue()
+ ->and($exchange->response()?->status)->toBe(GrpcStatus::Cancelled)
+ ->and($exchange->response()?->message)->toBe('Inbound gRPC call cancelled.');
+});
+
+it('prevents fake grpc inbound exchanges from completing twice', function (): void {
+ $exchange = new FakeGrpcInboundExchange(new GrpcInboundRequest('/Svc/Call', ['ok' => true]));
+ $exchange->complete(GrpcInboundResponse::ok(['ok' => true]));
+
+ expect(fn() => $exchange->complete(GrpcInboundResponse::ok()))
+ ->toThrow(LogicException::class, 'already been completed');
+});
diff --git a/tests/HttpConcurrentPoolTest.php b/tests/HttpConcurrentPoolTest.php
index fccdc42..50aa654 100644
--- a/tests/HttpConcurrentPoolTest.php
+++ b/tests/HttpConcurrentPoolTest.php
@@ -3,12 +3,166 @@
declare(strict_types=1);
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
use Infocyph\TalkingBytes\Http\Concurrent\CurlMultiTransport;
use Infocyph\TalkingBytes\Http\Concurrent\PoolResult;
use Infocyph\TalkingBytes\Http\HttpClient;
use Infocyph\TalkingBytes\Http\HttpRequest;
use Infocyph\TalkingBytes\Http\Transport\CurlTransport;
+
+final class ConcurrentHttpTestServer
+{
+ /**
+ * @param array $pipes
+ */
+ private function __construct(
+ private mixed $process,
+ private array $pipes,
+ private string $workDir,
+ public int $port,
+ ) {}
+
+ public function __destruct()
+ {
+ $this->stop();
+ }
+
+ /**
+ * @param array $delays
+ */
+ public static function start(array $delays): self
+ {
+ $workDir = sys_get_temp_dir() . '/talkingbytes-http-multi-' . bin2hex(random_bytes(6));
+ mkdir($workDir, 0775, true);
+
+ $scenarioPath = $workDir . '/scenario.json';
+ $readyPath = $workDir . '/ready.json';
+ $reportPath = $workDir . '/report.json';
+ file_put_contents($scenarioPath, json_encode($delays, JSON_THROW_ON_ERROR));
+
+ $process = proc_open(
+ [PHP_BINARY, __DIR__ . '/Fixtures/concurrent-http-server.php', $scenarioPath, $readyPath, $reportPath],
+ [
+ 0 => ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes,
+ );
+
+ if (!is_resource($process)) {
+ self::cleanupDirectory($workDir);
+
+ throw new RuntimeException('Unable to start concurrent HTTP test server.');
+ }
+
+ if (is_resource($pipes[0] ?? null)) {
+ fclose($pipes[0]);
+ $pipes[0] = null;
+ }
+
+ $port = self::waitForReadyPort($process, $pipes, $readyPath);
+
+ return new self($process, $pipes, $workDir, $port);
+ }
+
+ /**
+ * @return array
+ */
+ public function requestTimes(): array
+ {
+ $reportPath = $this->workDir . '/report.json';
+ $deadline = microtime(true) + 1.0;
+
+ while (!is_file($reportPath) && microtime(true) < $deadline) {
+ usleep(10_000);
+ }
+
+ $decoded = is_file($reportPath)
+ ? json_decode((string) file_get_contents($reportPath), true)
+ : null;
+ $times = is_array($decoded) ? ($decoded['request_times'] ?? []) : [];
+
+ if (!is_array($times)) {
+ return [];
+ }
+
+ $normalized = [];
+ foreach ($times as $path => $milliseconds) {
+ if (is_string($path) && is_int($milliseconds)) {
+ $normalized[$path] = $milliseconds;
+ }
+ }
+
+ return $normalized;
+ }
+
+ public function stop(): void
+ {
+ foreach ([1, 2] as $index) {
+ if (is_resource($this->pipes[$index] ?? null)) {
+ fclose($this->pipes[$index]);
+ $this->pipes[$index] = null;
+ }
+ }
+
+ if (is_resource($this->process)) {
+ $status = proc_get_status($this->process);
+ if ($status['running']) {
+ proc_terminate($this->process);
+ }
+
+ proc_close($this->process);
+ $this->process = null;
+ }
+
+ self::cleanupDirectory($this->workDir);
+ }
+
+ private static function cleanupDirectory(string $directory): void
+ {
+ foreach (glob($directory . '/*') ?: [] as $path) {
+ if (is_file($path)) {
+ unlink($path);
+ }
+ }
+
+ if (is_dir($directory)) {
+ rmdir($directory);
+ }
+ }
+
+ /**
+ * @param array $pipes
+ */
+ private static function waitForReadyPort(mixed $process, array $pipes, string $readyPath): int
+ {
+ $deadline = microtime(true) + 2.0;
+
+ while (microtime(true) < $deadline) {
+ if (is_file($readyPath)) {
+ $decoded = json_decode((string) file_get_contents($readyPath), true);
+ $port = is_array($decoded) ? ($decoded['port'] ?? null) : null;
+ if (is_int($port) && $port > 0) {
+ return $port;
+ }
+ }
+
+ $status = proc_get_status($process);
+ if (!$status['running']) {
+ break;
+ }
+
+ usleep(10_000);
+ }
+
+ $stderr = is_resource($pipes[2] ?? null) ? stream_get_contents($pipes[2]) : '';
+
+ throw new RuntimeException('Concurrent HTTP test server did not start: ' . $stderr);
+ }
+}
+
it('preserves request keys in concurrent pool results', function (): void {
$requests = [
'users' => HttpRequest::get('https://example.com/users')->blockHosts(['example.com']),
@@ -72,3 +226,91 @@
expect($singleResult->error)->toContain('cannot combine uploadFromFile/uploadFromStream');
expect($multiResult?->error)->toContain('cannot combine uploadFromFile/uploadFromStream');
});
+
+
+it('stops admitting new requests immediately after a preparation failure', function (): void {
+ $pool = HttpClient::multi(maxConcurrency: 3)->stopSchedulingOnFailure();
+
+ $result = $pool->sendMany([
+ 'first' => HttpRequest::get('https://example.com/first')->blockHosts(['example.com']),
+ 'second' => HttpRequest::get('https://example.com/second')->blockHosts(['example.com']),
+ 'third' => HttpRequest::get('https://example.com/third')->blockHosts(['example.com']),
+ ]);
+
+ expect(array_keys($result->all()))->toBe(['first']);
+ expect($result->metadata['stopped_scheduling'] ?? null)->toBeTrue();
+ expect($result->metadata['cancelled'] ?? null)->toBeFalse();
+});
+
+it('returns deterministic cancelled results before pool scheduling starts', function (): void {
+ $pool = HttpClient::multi(maxConcurrency: 2)
+ ->withCancellation(CancellationSignal::fromCallable(static fn(): bool => true));
+
+ $result = $pool->sendMany([
+ 'first' => HttpRequest::get('https://example.com/first'),
+ 'second' => HttpRequest::get('https://example.com/second'),
+ 'third' => HttpRequest::get('https://example.com/third'),
+ ]);
+
+ expect(array_keys($result->all()))->toBe(['first', 'second', 'third']);
+ expect($result->metadata['stopped_scheduling'] ?? null)->toBeTrue();
+ expect($result->metadata['cancelled'] ?? null)->toBeTrue();
+
+ foreach ($result->all() as $cancelled) {
+ expect($cancelled->successful)->toBeFalse();
+ expect($cancelled->metadata['cancelled'] ?? null)->toBeTrue();
+ expect($cancelled->metadata['started'] ?? null)->toBeFalse();
+ }
+});
+
+
+it('refills the rolling window as soon as a fast request completes', function (): void {
+ $server = ConcurrentHttpTestServer::start([
+ '/slow' => 700,
+ '/fast-one' => 100,
+ '/fast-two' => 10,
+ ]);
+
+ try {
+ $baseUrl = sprintf('http://127.0.0.1:%d', $server->port);
+ $result = HttpClient::multi(maxConcurrency: 2)->sendMany([
+ 'slow' => HttpRequest::get($baseUrl . '/slow'),
+ 'fast-one' => HttpRequest::get($baseUrl . '/fast-one'),
+ 'fast-two' => HttpRequest::get($baseUrl . '/fast-two'),
+ ]);
+
+ expect($result->successfulCount())->toBe(3);
+ expect(array_keys($result->all()))->toBe(['slow', 'fast-one', 'fast-two']);
+
+ $times = $server->requestTimes();
+ expect($times)->toHaveKeys(['/slow', '/fast-one', '/fast-two']);
+ expect($times['/fast-two'] - $times['/slow'])->toBeLessThan(350);
+ } finally {
+ $server->stop();
+ }
+});
+
+it('cancels active curl handles cooperatively', function (): void {
+ $server = ConcurrentHttpTestServer::start(['/slow' => 1000]);
+
+ try {
+ $baseUrl = sprintf('http://127.0.0.1:%d', $server->port);
+ $startedAt = microtime(true);
+ $cancellation = CancellationSignal::fromCallable(
+ static fn(): bool => (microtime(true) - $startedAt) >= 0.1,
+ );
+
+ $result = HttpClient::multi(maxConcurrency: 1, cancellation: $cancellation)->sendMany([
+ 'slow' => HttpRequest::get($baseUrl . '/slow'),
+ ]);
+
+ $cancelled = $result->get('slow');
+ expect($cancelled)->toBeInstanceOf(CommunicationResult::class);
+ expect($cancelled?->successful)->toBeFalse();
+ expect($cancelled?->metadata['cancelled'] ?? null)->toBeTrue();
+ expect($cancelled?->metadata['started'] ?? null)->toBeTrue();
+ expect($result->metadata['cancelled'] ?? null)->toBeTrue();
+ } finally {
+ $server->stop();
+ }
+});
diff --git a/tests/HttpStreamingTest.php b/tests/HttpStreamingTest.php
index 1acd8ce..313da8b 100644
--- a/tests/HttpStreamingTest.php
+++ b/tests/HttpStreamingTest.php
@@ -224,3 +224,16 @@
}
fclose($stream);
});
+
+
+it('aborts streamed downloads without publishing partial files', function (): void {
+ $target = sys_get_temp_dir() . '/tb-http-stream-' . bin2hex(random_bytes(6)) . '.txt';
+ $request = HttpRequest::get('https://example.com')->streamDownloadTo($target);
+ $collector = new ResponseBodyCollector($request);
+
+ expect($collector->collect('partial'))->toBe(7);
+ $collector->abort();
+
+ expect(is_file($target))->toBeFalse();
+ expect($collector->collect('late'))->toBe(0);
+});
diff --git a/tests/MailboxCommandRedactorTest.php b/tests/MailboxCommandRedactorTest.php
index 9629bda..eff0ebd 100644
--- a/tests/MailboxCommandRedactorTest.php
+++ b/tests/MailboxCommandRedactorTest.php
@@ -7,7 +7,7 @@
it('redacts sensitive imap login and authenticate commands', function (): void {
expect(MailboxCommandRedactor::redact('imap', 'LOGIN "user" "secret"'))
- ->toBe('LOGIN "user" [REDACTED]');
+ ->toBe('LOGIN [REDACTED] [REDACTED]');
expect(MailboxCommandRedactor::redact('imap', 'AUTHENTICATE PLAIN dXNlcgB1c2VyAHNlY3JldA=='))
->toBe('AUTHENTICATE [REDACTED]');
@@ -21,13 +21,13 @@
->toBe('PASS [REDACTED]');
expect(MailboxCommandRedactor::redact('pop3', 'APOP user deadbeef123'))
- ->toBe('APOP user [REDACTED]');
+ ->toBe('APOP [REDACTED] [REDACTED]');
expect(MailboxCommandRedactor::redact('pop3', 'AUTH PLAIN dGVzdA=='))
->toBe('AUTH [REDACTED]');
expect(MailboxCommandRedactor::redact('pop3', 'USER test'))
- ->toBe('USER test');
+ ->toBe('USER [REDACTED]');
});
it('dispatches redacted IMAP command payloads in start and finish events', function (): void {
@@ -44,8 +44,8 @@
expect($events[0]['event'] ?? null)->toBe('mailbox.command.start');
expect($events[1]['event'] ?? null)->toBe('mailbox.command.finish');
- expect($events[0]['command'] ?? '')->toBe('LOGIN "user" [REDACTED]');
- expect($events[1]['command'] ?? '')->toBe('LOGIN "user" [REDACTED]');
+ expect($events[0]['command'] ?? '')->toBe('LOGIN [REDACTED] [REDACTED]');
+ expect($events[1]['command'] ?? '')->toBe('LOGIN [REDACTED] [REDACTED]');
});
it('dispatches redacted IMAP AUTHENTICATE payloads in mailbox events', function (): void {
@@ -88,8 +88,8 @@
expect($events[1]['event'] ?? null)->toBe('mailbox.command.finish');
expect($events[0]['command'] ?? '')->toBe('PASS [REDACTED]');
expect($events[1]['command'] ?? '')->toBe('PASS [REDACTED]');
- expect($events[2]['command'] ?? '')->toBe('APOP user [REDACTED]');
- expect($events[3]['command'] ?? '')->toBe('APOP user [REDACTED]');
+ expect($events[2]['command'] ?? '')->toBe('APOP [REDACTED] [REDACTED]');
+ expect($events[3]['command'] ?? '')->toBe('APOP [REDACTED] [REDACTED]');
expect($events[4]['command'] ?? '')->toBe('AUTH [REDACTED]');
expect($events[5]['command'] ?? '')->toBe('AUTH [REDACTED]');
});
diff --git a/tests/MailboxImapTest.php b/tests/MailboxImapTest.php
index 221f712..68762cc 100644
--- a/tests/MailboxImapTest.php
+++ b/tests/MailboxImapTest.php
@@ -2,8 +2,9 @@
declare(strict_types=1);
+use Infocyph\TalkingBytes\Core\Event\CallableEventDispatcher;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
use Infocyph\TalkingBytes\Email\Config\ImapConfig;
-use Infocyph\TalkingBytes\Email\Email;
use Infocyph\TalkingBytes\Email\Enum\ImapSecurity;
use Infocyph\TalkingBytes\Email\Exception\MailboxConnectionException;
use Infocyph\TalkingBytes\Email\Exception\MailboxProtocolException;
@@ -394,6 +395,23 @@ private static function script(): string
))->toBeFalse();
});
+it('exposes explicit mailbox session lifecycle and cancellation helpers', function (): void {
+ $fake = FakeMailbox::new();
+ $events = 0;
+
+ $fake->mailbox->connect();
+ $fake->mailbox->watchUntilCancelled(
+ 'INBOX',
+ static function () use (&$events): void {
+ $events++;
+ },
+ CancellationSignal::fromCallable(static fn(): bool => true),
+ );
+ $fake->mailbox->logout();
+
+ expect($events)->toBe(0);
+});
+
it('supports fake mailbox operations and parsing workflow', function (): void {
$fake = FakeMailbox::new();
@@ -677,7 +695,7 @@ static function (string $event) use (&$events, &$done): void {
it('redacts IMAP LOGIN password in mailbox command events', function (): void {
$events = [];
- Email::events(static function (string $event, array $payload) use (&$events): void {
+ $dispatcher = new CallableEventDispatcher(static function (string $event, array $payload) use (&$events): void {
if (str_starts_with($event, 'mailbox.command.')) {
$events[] = $payload;
}
@@ -692,26 +710,29 @@ static function (string $event) use (&$events, &$done): void {
],
]);
- $mailbox = Mailbox::usingImap(new ImapConfig(
- host: '127.0.0.1',
- port: $server->port,
- security: ImapSecurity::None,
- username: 'user',
- password: 'pass',
- ));
+ $mailbox = Mailbox::usingImap(
+ new ImapConfig(
+ host: '127.0.0.1',
+ port: $server->port,
+ security: ImapSecurity::None,
+ username: 'user',
+ password: 'pass',
+ ),
+ events: $dispatcher,
+ );
$mailbox->folders();
$mailbox->transport()->logout();
$server->stop();
- Email::events(null);
expect(array_any(
$events,
- static fn (array $payload): bool => ($payload['command'] ?? null) === 'LOGIN "user" [REDACTED]',
+ static fn (array $payload): bool => ($payload['command'] ?? null) === 'LOGIN [REDACTED] [REDACTED]',
))->toBeTrue();
expect(array_any(
$events,
- static fn (array $payload): bool => is_string($payload['command'] ?? null) && str_contains($payload['command'], 'LOGIN "user" "pass"'),
+ static fn (array $payload): bool => is_string($payload['command'] ?? null)
+ && (str_contains($payload['command'], '"user"') || str_contains($payload['command'], '"pass"')),
))->toBeFalse();
});
diff --git a/tests/MonotonicTransportTimingTest.php b/tests/MonotonicTransportTimingTest.php
new file mode 100644
index 0000000..7af8075
--- /dev/null
+++ b/tests/MonotonicTransportTimingTest.php
@@ -0,0 +1,157 @@
+ 1_700_000_000.0,
+ static function () use (&$now): float {
+ $current = $now;
+ $now += 0.25;
+
+ return $current;
+ },
+ );
+ $events = [];
+ $dispatcher = new CallableEventDispatcher(
+ static function (string $event, array $payload) use (&$events): void {
+ $events[$event] = $payload;
+ },
+ );
+
+ $result = (new CurlTransport($dispatcher, $clock))->send(
+ HttpRequest::get('https://example.test')->blockHosts(['example.test']),
+ );
+
+ expect($result->successful)->toBeFalse()
+ ->and($events['http.request.failed']['duration_ms'] ?? null)->toBe(250);
+});
+
+it('uses the injected monotonic clock for unary gRPC duration', function (): void {
+ $now = 20.0;
+ $clock = new Clock(
+ static fn(): float => 1_700_000_000.0,
+ static function () use (&$now): float {
+ $current = $now;
+ $now += 0.125;
+
+ return $current;
+ },
+ );
+ $events = [];
+ $dispatcher = new CallableEventDispatcher(
+ static function (string $event, array $payload) use (&$events): void {
+ $events[$event] = $payload;
+ },
+ );
+ $transport = new GrpcTransport(
+ static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse(GrpcStatus::Ok, $request->message),
+ $dispatcher,
+ $clock,
+ );
+
+ $result = $transport->send(new GrpcRequest('/runtime.v1.Health/Check', ['ok' => true]));
+
+ expect($result->successful)->toBeTrue()
+ ->and($events['grpc.request.finish']['duration_ms'] ?? null)->toBe(125);
+});
+
+it('preserves the injected monotonic clock across gRPC immutable streaming graphs', function (): void {
+ $now = 30.0;
+ $clock = new Clock(
+ static fn(): float => 1_700_000_000.0,
+ static function () use (&$now): float {
+ $current = $now;
+ $now += 0.25;
+
+ return $current;
+ },
+ );
+ $events = [];
+ $dispatcher = new CallableEventDispatcher(
+ static function (string $event, array $payload) use (&$events): void {
+ $events[$event] = $payload;
+ },
+ );
+
+ $invoker = new class implements NativeGrpcInvoker, NativeGrpcStreamingInvoker {
+ public function invoke(
+ string $method,
+ mixed $message,
+ GrpcMetadata $headers,
+ ?float $deadlineSeconds = null,
+ ): NativeGrpcResult {
+ unset($method, $headers, $deadlineSeconds);
+
+ return new NativeGrpcResult(GrpcStatus::Ok->value, $message);
+ }
+
+ public function bidiStream(
+ string $method,
+ iterable $messages,
+ GrpcMetadata $headers,
+ callable $onMessage,
+ ?float $deadlineSeconds = null,
+ ): NativeGrpcResult {
+ unset($method, $headers, $deadlineSeconds);
+
+ foreach ($messages as $message) {
+ $onMessage($message);
+ }
+
+ return new NativeGrpcResult(GrpcStatus::Ok->value);
+ }
+
+ public function clientStream(
+ string $method,
+ iterable $messages,
+ GrpcMetadata $headers,
+ ?float $deadlineSeconds = null,
+ ): NativeGrpcResult {
+ unset($method, $headers, $deadlineSeconds);
+
+ foreach ($messages as $message) {
+ unset($message);
+ }
+
+ return new NativeGrpcResult(GrpcStatus::Ok->value);
+ }
+
+ public function serverStream(
+ string $method,
+ mixed $message,
+ GrpcMetadata $headers,
+ callable $onMessage,
+ ?float $deadlineSeconds = null,
+ ): NativeGrpcResult {
+ unset($method, $headers, $deadlineSeconds);
+
+ $onMessage($message);
+
+ return new NativeGrpcResult(GrpcStatus::Ok->value);
+ }
+ };
+
+ $client = GrpcClient::usingNativeStreaming($invoker, $invoker, $dispatcher, $clock)
+ ->withMiddlewares([]);
+
+ $result = $client->clientStream('/runtime.v1.Health/Stream', [['ok' => true]]);
+
+ expect($result->successful)->toBeTrue()
+ ->and($events['grpc.stream.finish']['duration_ms'] ?? null)->toBe(250);
+});
diff --git a/tests/MutableStateIsolationTest.php b/tests/MutableStateIsolationTest.php
new file mode 100644
index 0000000..1098e2f
--- /dev/null
+++ b/tests/MutableStateIsolationTest.php
@@ -0,0 +1,96 @@
+withDefaultHeaders(['X-Profile' => 'derived'])
+ ->withBearerToken('derived-token');
+
+ $base->get('https://example.test/base');
+ $derived->get('https://example.test/derived');
+
+ $requests = $transport->sentRequests();
+
+ expect($requests)->toHaveCount(2)
+ ->and($requests[0]->headers->get('X-Profile'))->toBeNull()
+ ->and($requests[0]->headers->get('Authorization'))->toBeNull()
+ ->and($requests[1]->headers->get('X-Profile'))->toBe('derived')
+ ->and($requests[1]->headers->get('Authorization'))->toBe('Bearer derived-token');
+});
+
+it('keeps cookie jars isolated by instance', function (): void {
+ $first = new CookieJar();
+ $second = new CookieJar();
+
+ $first->remember(new Cookie('session', 'first', 'example.test'));
+
+ expect($first->count())->toBe(1)
+ ->and($second->count())->toBe(0)
+ ->and($second->all())->toBe([]);
+});
+
+it('keeps circuit breaker state isolated by instance', function (): void {
+ $first = new CircuitBreaker(failureThreshold: 1);
+ $second = new CircuitBreaker(failureThreshold: 1);
+
+ $first->onFailure();
+
+ expect($first->state())->toBe(CircuitState::Open)
+ ->and($second->state())->toBe(CircuitState::Closed);
+});
+
+it('keeps rate limiter token state isolated by instance', function (): void {
+ $first = new RateLimiter(1, 60);
+ $second = new RateLimiter(1, 60);
+
+ $first->assertCanProceed();
+
+ expect(static fn() => $first->assertCanProceed())
+ ->toThrow(RuntimeException::class, 'Rate limit exceeded.');
+
+ $second->assertCanProceed();
+ expect(true)->toBeTrue();
+});
+
+it('keeps cookie sessions isolated when persistent-runtime fibers interleave', function (): void {
+ $transportA = (new FakeHttpTransport())
+ ->pushJson(['ok' => true], 200, ['Set-Cookie' => 'session=a; Path=/'])
+ ->pushJson(['ok' => true], 200);
+ $transportB = (new FakeHttpTransport())
+ ->pushJson(['ok' => true], 200, ['Set-Cookie' => 'session=b; Path=/'])
+ ->pushJson(['ok' => true], 200);
+
+ $clientA = HttpClient::fake($transportA)->withCookieJar(new CookieJar());
+ $clientB = HttpClient::fake($transportB)->withCookieJar(new CookieJar());
+
+ $fiberA = new Fiber(static function () use ($clientA): void {
+ $clientA->get('https://a.example.test/login');
+ Fiber::suspend();
+ $clientA->get('https://a.example.test/orders');
+ });
+
+ $fiberA->start();
+ $clientB->get('https://b.example.test/login');
+ $clientB->get('https://b.example.test/orders');
+ $fiberA->resume();
+
+ $requestsA = $transportA->sentRequests();
+ $requestsB = $transportB->sentRequests();
+
+ expect((string) $requestsA[1]->headers->get('Cookie'))->toContain('session=a')
+ ->and((string) $requestsA[1]->headers->get('Cookie'))->not->toContain('session=b')
+ ->and((string) $requestsB[1]->headers->get('Cookie'))->toContain('session=b')
+ ->and((string) $requestsB[1]->headers->get('Cookie'))->not->toContain('session=a')
+ ->and($fiberA->isTerminated())->toBeTrue();
+});
diff --git a/tests/ObservabilitySanitizationTest.php b/tests/ObservabilitySanitizationTest.php
new file mode 100644
index 0000000..491da1d
--- /dev/null
+++ b/tests/ObservabilitySanitizationTest.php
@@ -0,0 +1,143 @@
+ 'Bearer sentinel-auth',
+ 'Proxy-Authorization' => 'Basic sentinel-proxy',
+ 'Cookie' => 'session=sentinel-cookie',
+ 'X-Api-Key' => 'sentinel-api-key',
+ 'X-TB-Signature' => 't=1,v1=sentinel-signature',
+ 'Accept' => 'application/json',
+ ]);
+
+ expect($headers['Authorization'])->toBe('[REDACTED]')
+ ->and($headers['Proxy-Authorization'])->toBe('[REDACTED]')
+ ->and($headers['Cookie'])->toBe('[REDACTED]')
+ ->and($headers['X-Api-Key'])->toBe('[REDACTED]')
+ ->and($headers['X-TB-Signature'])->toBe('[REDACTED]')
+ ->and($headers['Accept'])->toBe('application/json');
+});
+
+it('keeps webhook failure events free of raw errors and secrets', function (): void {
+ $events = [];
+ $dispatcher = new CallableEventDispatcher(static function (string $event, array $payload) use (&$events): void {
+ if (str_starts_with($event, 'webhook.')) {
+ $events[] = [$event, $payload];
+ }
+ });
+ $transport = new SequenceHttpTransport([
+ CommunicationResult::failure('sentinel-webhook-error', 503),
+ ]);
+ $sender = (new WebhookSender(HttpClient::using($transport), events: $dispatcher))
+ ->withSecret('sentinel-webhook-signing-secret');
+
+ $sender->send(
+ WebhookMessage::new('order.created')
+ ->url('https://hooks.example.test/orders?token=sentinel-query-secret')
+ ->payload(['private' => 'sentinel-body-secret']),
+ );
+
+ $encoded = observabilityJson($events);
+ expect($encoded)->not->toContain('sentinel-webhook-error')
+ ->and($encoded)->not->toContain('sentinel-webhook-signing-secret')
+ ->and($encoded)->not->toContain('sentinel-body-secret')
+ ->and($encoded)->not->toContain('sentinel-query-secret')
+ ->and($encoded)->toContain('failure_category');
+});
+
+it('keeps email events and logging decorators free of subject and raw errors', function (): void {
+ $events = [];
+ $logs = [];
+ $dispatcher = new CallableEventDispatcher(static function (string $event, array $payload) use (&$events): void {
+ $events[] = [$event, $payload];
+ });
+ $transport = new class implements EmailTransport {
+ public function send(EmailMessage $message): CommunicationResult
+ {
+ unset($message);
+
+ return CommunicationResult::failure(
+ 'sentinel-email-error',
+ metadata: ['transport' => 'sentinel-transport', 'secret' => 'sentinel-metadata-secret'],
+ );
+ }
+ };
+ $message = EmailMessage::new()
+ ->from('sender@example.test')
+ ->to('private-person@example.test')
+ ->subject('sentinel-private-subject')
+ ->text('sentinel-private-body');
+
+ (new Emailer($transport, $dispatcher))->send($message);
+ (new LoggingEmailTransport(
+ $transport,
+ static function (string $event, array $context) use (&$logs): void {
+ $logs[] = [$event, $context];
+ },
+ ))->send($message);
+
+ $encoded = observabilityJson([...$events, ...$logs]);
+ expect($encoded)->not->toContain('sentinel-email-error')
+ ->and($encoded)->not->toContain('sentinel-private-subject')
+ ->and($encoded)->not->toContain('private-person@example.test')
+ ->and($encoded)->not->toContain('sentinel-private-body')
+ ->and($encoded)->not->toContain('sentinel-metadata-secret')
+ ->and($encoded)->toContain('failure_category');
+});
+
+it('keeps grpc events free of exception messages and metadata values', function (): void {
+ $events = [];
+ $dispatcher = new CallableEventDispatcher(static function (string $event, array $payload) use (&$events): void {
+ if (str_starts_with($event, 'grpc.')) {
+ $events[] = [$event, $payload];
+ }
+ });
+ $headers = (new GrpcMetadata())->withValue('authorization', 'sentinel-grpc-metadata');
+ $client = GrpcClient::using(
+ static function (GrpcRequest $request): never {
+ expect($request->headers->first('authorization'))->toBe('sentinel-grpc-metadata');
+ throw new RuntimeException('sentinel-grpc-error');
+ },
+ $dispatcher,
+ );
+
+ $client->send(new GrpcRequest('/example.Service/Call', ['secret' => 'sentinel-grpc-body'], $headers));
+
+ $encoded = observabilityJson($events);
+ expect($encoded)->not->toContain('sentinel-grpc-error')
+ ->and($encoded)->not->toContain('sentinel-grpc-metadata')
+ ->and($encoded)->not->toContain('sentinel-grpc-body')
+ ->and($encoded)->toContain(RuntimeException::class);
+});
+
+it('removes mailbox auth usernames and credentials from diagnostics', function (): void {
+ expect(MailboxCommandRedactor::redact('imap', 'LOGIN "sentinel-user" "sentinel-password"'))
+ ->toBe('LOGIN [REDACTED] [REDACTED]')
+ ->and(MailboxCommandRedactor::redact('pop3', 'USER sentinel-user'))->toBe('USER [REDACTED]')
+ ->and(MailboxCommandRedactor::redact('pop3', 'PASS sentinel-password'))->toBe('PASS [REDACTED]')
+ ->and(MailboxCommandRedactor::redact('pop3', 'APOP sentinel-user sentinel-digest'))
+ ->toBe('APOP [REDACTED] [REDACTED]');
+});
diff --git a/tests/OptionalCapabilityColdnessTest.php b/tests/OptionalCapabilityColdnessTest.php
new file mode 100644
index 0000000..dbcb027
--- /dev/null
+++ b/tests/OptionalCapabilityColdnessTest.php
@@ -0,0 +1,169 @@
+ OPENSSL_KEYTYPE_RSA,
+ 'private_key_bits' => 2048,
+ ]);
+
+ if ($key === false) {
+ throw new RuntimeException('Unable to generate RSA key for optional-capability coldness test.');
+ }
+
+ $privateKey = '';
+ if (!openssl_pkey_export($key, $privateKey)) {
+ throw new RuntimeException('Unable to export RSA key for optional-capability coldness test.');
+ }
+
+ return $privateKey;
+}
+
+it('keeps unrelated protocol graphs usable without optional runtime extensions', function (): void {
+ if (!talkingBytesOptionalColdnessEnabled()) {
+ expect(true)->toBeTrue();
+
+ return;
+ }
+
+ foreach (['grpc', 'imap', 'posix'] as $extension) {
+ expect(extension_loaded($extension))->toBeFalse();
+ }
+
+ expect(class_exists('Grpc\\Channel'))->toBeFalse();
+
+ $http = HttpClient::using(new FakeHttpTransport());
+ expect($http->send(HttpRequest::get('https://example.test/health'))->successful)->toBeTrue();
+
+ $webhook = Webhook::sender($http)->send(
+ WebhookMessage::new('runtime.coldness')
+ ->url('https://example.test/webhook')
+ ->payload(['ok' => true]),
+ );
+ expect($webhook->result->successful)->toBeTrue();
+
+ $email = Email::sender()->usingNull();
+ expect($email->send(
+ EmailMessage::new()
+ ->from('sender@example.test')
+ ->to('recipient@example.test')
+ ->subject('Cold runtime')
+ ->text('ok'),
+ )->successful)->toBeTrue();
+
+ $smtp = Email::sender()->usingSmtp(new SmtpConfig('smtp.example.test'));
+ expect($smtp)->toBeInstanceOf(Emailer::class);
+
+ $grpc = GrpcClient::using(
+ static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse(
+ GrpcStatus::Ok,
+ $request->message,
+ ),
+ );
+ $grpcResult = $grpc->send(new GrpcRequest('/runtime.v1.Health/Check', ['ok' => true]));
+
+ expect($grpcResult->successful)->toBeTrue()
+ ->and(class_exists('Grpc\\Channel'))->toBeFalse();
+});
+
+it('keeps RSA DKIM independent from Sodium', function (): void {
+ if (!talkingBytesOptionalColdnessEnabled()) {
+ expect(true)->toBeTrue();
+
+ return;
+ }
+
+ $config = DkimConfig::fromPrivateKeyString(
+ 'example.test',
+ 'selector',
+ talkingBytesColdnessRsaPrivateKey(),
+ );
+
+ expect($config->algorithm)->toBe(DkimAlgorithm::RsaSha256);
+});
+
+it('fails Ed25519 DKIM clearly only when the capability is selected', function (): void {
+ if (!talkingBytesOptionalColdnessEnabled()) {
+ expect(true)->toBeTrue();
+
+ return;
+ }
+
+ $build = static fn(): DkimConfig => DkimConfig::fromPrivateKeyString(
+ 'example.test',
+ 'selector',
+ base64_encode(random_bytes(32)),
+ algorithm: DkimAlgorithm::Ed25519Sha256,
+ );
+
+ if (!function_exists('sodium_crypto_sign_detached')) {
+ expect($build)->toThrow(RuntimeException::class, 'Sodium extension is required for Ed25519 DKIM signing.');
+
+ return;
+ }
+
+ expect($build()->algorithm)->toBe(DkimAlgorithm::Ed25519Sha256);
+});
+
+
+it('confines compiled-in optional capabilities to their selected feature boundary', function (): void {
+ $root = dirname(__DIR__) . '/src';
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root));
+ $pcntlReferences = [];
+ $unexpectedSodiumReferences = [];
+ $allowedSodiumFiles = [
+ 'Email/Config/DkimConfig.php',
+ 'Email/Dkim/DkimSigner.php',
+ 'Email/Dkim/DkimVerifier.php',
+ 'Email/Dkim/DkimPublicKeyParser.php',
+ ];
+
+ foreach ($iterator as $file) {
+ if (!$file instanceof SplFileInfo || !$file->isFile() || $file->getExtension() !== 'php') {
+ continue;
+ }
+
+ $contents = file_get_contents($file->getPathname());
+ if (!is_string($contents)) {
+ continue;
+ }
+
+ $relative = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1));
+ if (preg_match('/\\bpcntl_/', $contents) === 1) {
+ $pcntlReferences[] = $relative;
+ }
+
+ if (
+ preg_match('/\\bsodium_/', $contents) === 1
+ && !in_array($relative, $allowedSodiumFiles, true)
+ ) {
+ $unexpectedSodiumReferences[] = $relative;
+ }
+ }
+
+ expect($pcntlReferences)->toBe([])
+ ->and($unexpectedSodiumReferences)->toBe([]);
+});
diff --git a/tests/Pop3MailboxTest.php b/tests/Pop3MailboxTest.php
index 1b7a458..ad2968b 100644
--- a/tests/Pop3MailboxTest.php
+++ b/tests/Pop3MailboxTest.php
@@ -2,8 +2,8 @@
declare(strict_types=1);
+use Infocyph\TalkingBytes\Core\Event\CallableEventDispatcher;
use Infocyph\TalkingBytes\Email\Config\Pop3Config;
-use Infocyph\TalkingBytes\Email\Email;
use Infocyph\TalkingBytes\Email\Enum\Pop3Security;
use Infocyph\TalkingBytes\Email\Exception\MailboxAuthenticationException;
use Infocyph\TalkingBytes\Email\Exception\MailboxConnectionException;
@@ -384,13 +384,12 @@ private static function waitForReadyPort(string $readyPath, mixed $process, arra
expect($transcript['mismatches'])->toBe([]);
});
-it('fails pop3 authentication when server rejects password', function (): void {
+it('fails pop3 authentication and drops the poisoned connection', function (): void {
$server = FakePop3ServerProcess::start([
'expect' => [
['regex' => '/^CAPA$/', 'multiline' => ['UIDL']],
['regex' => '/^USER user$/'],
['regex' => '/^PASS wrong$/', 'status' => '-ERR', 'text' => 'invalid login'],
- ['regex' => '/^QUIT$/'],
],
]);
@@ -404,8 +403,12 @@ private static function waitForReadyPort(string $readyPath, mixed $process, arra
expect(fn() => $mailbox->status())->toThrow(MailboxAuthenticationException::class);
+ $transcript = $server->transcript();
$mailbox->logout();
$server->stop();
+
+ expect($transcript['mismatches'])->toBe([]);
+ expect($transcript['commands'])->toBe(['CAPA', 'USER user', 'PASS wrong']);
});
it('rejects unsupported pop3 folder operations and non-all searches', function (): void {
@@ -525,7 +528,7 @@ private static function waitForReadyPort(string $readyPath, mixed $process, arra
it('redacts POP3 PASS value in mailbox command events', function (): void {
$events = [];
- Email::events(static function (string $event, array $payload) use (&$events): void {
+ $dispatcher = new CallableEventDispatcher(static function (string $event, array $payload) use (&$events): void {
if (str_starts_with($event, 'mailbox.command.')) {
$events[] = $payload;
}
@@ -541,18 +544,20 @@ private static function waitForReadyPort(string $readyPath, mixed $process, arra
],
]);
- $mailbox = Pop3Mailbox::usingConfig(new Pop3Config(
- host: '127.0.0.1',
- port: $server->port,
- security: Pop3Security::None,
- username: 'user',
- password: 'pass',
- ));
+ $mailbox = Pop3Mailbox::usingConfig(
+ new Pop3Config(
+ host: '127.0.0.1',
+ port: $server->port,
+ security: Pop3Security::None,
+ username: 'user',
+ password: 'pass',
+ ),
+ events: $dispatcher,
+ );
$mailbox->status();
$mailbox->logout();
$server->stop();
- Email::events(null);
expect(array_any(
$events,
diff --git a/tests/ResolvedCompositionTest.php b/tests/ResolvedCompositionTest.php
new file mode 100644
index 0000000..458ba75
--- /dev/null
+++ b/tests/ResolvedCompositionTest.php
@@ -0,0 +1,224 @@
+ 7,
+ 'auth' => [
+ 'driver' => 'bearer',
+ 'token' => 'resolved-token',
+ ],
+ 'cookies' => ['enabled' => true],
+ 'retry' => [
+ 'enabled' => true,
+ 'attempts' => 2,
+ 'base_delay_ms' => 0,
+ 'max_retry_after_seconds' => 1,
+ ],
+ 'rate_limit' => [
+ 'enabled' => true,
+ 'max_requests' => 10,
+ 'per_seconds' => 60,
+ ],
+ 'circuit_breaker' => [
+ 'enabled' => true,
+ 'failure_threshold' => 3,
+ 'cool_down_seconds' => 5,
+ ],
+ 'idempotency' => [
+ 'enabled' => true,
+ 'header' => 'Idempotency-Key',
+ ],
+ ], transport: $transport);
+
+ $result = $client->send(HttpRequest::post('https://example.test/orders')->json(['id' => 1]));
+ $request = $transport->sentRequests()[0];
+
+ expect($result->successful)->toBeTrue()
+ ->and($client->hasRetryMiddleware())->toBeTrue()
+ ->and($request->headers->get('Authorization'))->toBe('Bearer resolved-token')
+ ->and($request->headers->get('Idempotency-Key'))->toMatch('/^[a-f0-9]{32}$/')
+ ->and($request->options->timeoutSeconds)->toBe(7);
+});
+
+it('parses email limits and composes resolved sender transport decorators', function (): void {
+ $limits = EmailLimits::fromArray([
+ 'maxMessageBytes' => '2048',
+ 'maxAttachmentBytes' => 4096,
+ 'maxHeaderLineBytes' => '900',
+ ]);
+
+ $key = openssl_pkey_new([
+ 'private_key_bits' => 2048,
+ 'private_key_type' => OPENSSL_KEYTYPE_RSA,
+ ]);
+ expect($key)->not->toBeFalse();
+
+ $privateKey = '';
+ expect(openssl_pkey_export($key, $privateKey))->toBeTrue();
+
+ $emailer = (new EmailSenderFactory())->fromResolvedConfig([
+ 'transport' => ['driver' => 'null'],
+ 'fallbacks' => [
+ ['driver' => 'fake'],
+ ],
+ 'retry' => [
+ 'enabled' => true,
+ 'policy' => 'fixed',
+ 'max_attempts' => 2,
+ 'delay_ms' => 0,
+ ],
+ 'rate_limit' => [
+ 'enabled' => true,
+ 'max_requests' => 10,
+ 'per_seconds' => 60,
+ ],
+ 'dkim' => [
+ 'enabled' => true,
+ 'domain' => 'example.test',
+ 'selector' => 'mail',
+ 'private_key' => $privateKey,
+ 'headers' => ['from', 'to', 'subject'],
+ ],
+ ]);
+
+ $result = $emailer->send(
+ EmailMessage::new()
+ ->from('sender@example.test')
+ ->to('recipient@example.test')
+ ->subject('Resolved sender')
+ ->text('ok'),
+ );
+
+ expect($limits->maxMessageBytes)->toBe(2048)
+ ->and($limits->maxAttachmentBytes)->toBe(4096)
+ ->and($limits->maxHeaderLineBytes)->toBe(900)
+ ->and($emailer->transport())->toBeInstanceOf(DkimSigningTransport::class)
+ ->and($result->successful)->toBeTrue();
+});
+
+it('builds grpc retry and generated stub clients from resolved protocol config', function (): void {
+ $attempts = 0;
+ $client = (new GrpcClientFactory())->using(
+ static function (GrpcRequest $request) use (&$attempts): GrpcResponse {
+ unset($request);
+ $attempts++;
+
+ return new GrpcResponse(
+ status: $attempts === 1 ? GrpcStatus::Unavailable : GrpcStatus::Ok,
+ message: ['attempt' => $attempts],
+ );
+ },
+ [
+ 'retry' => [
+ 'enabled' => true,
+ 'attempts' => 2,
+ 'base_delay_ms' => 0,
+ 'jitter_ratio' => 0,
+ ],
+ ],
+ );
+
+ $result = $client->send(
+ (new GrpcRequest('/orders.v1.OrderService/Create', ['id' => 1]))
+ ->withRetrySafety(),
+ );
+
+ $stub = new class {
+ public function Ping(mixed $message, array $metadata = [], array $options = []): object
+ {
+ unset($metadata, $options);
+
+ return new class($message) {
+ public function __construct(private readonly mixed $message) {}
+
+ public function wait(): array
+ {
+ return [['echo' => $this->message], ['code' => 0]];
+ }
+ };
+ }
+ };
+
+ $generated = GrpcClient::usingGeneratedStub($stub);
+ $generatedResult = $generated->send(new GrpcRequest('/example.PingService/Ping', ['ping' => true]));
+
+ expect($result->successful)->toBeTrue()
+ ->and($attempts)->toBe(2)
+ ->and($generatedResult->successful)->toBeTrue()
+ ->and($generatedResult->response->message['echo']['ping'] ?? false)->toBeTrue();
+});
+
+it('builds webhook signing retry and replay-aware receiver policies from resolved config', function (): void {
+ $transport = new SequenceHttpTransport([
+ CommunicationResult::failure(
+ 'temporary',
+ 503,
+ new HttpResponse(503, '{"retry":true}'),
+ ),
+ CommunicationResult::success(
+ 200,
+ new HttpResponse(200, '{"ok":true}'),
+ ),
+ ]);
+
+ $sender = Webhook::senderFromResolvedConfig(
+ HttpClient::using($transport),
+ [
+ 'signing_secret' => 'whsec_resolved',
+ 'retry' => [
+ 'enabled' => true,
+ 'attempts' => 2,
+ 'base_delay_ms' => 0,
+ 'max_retry_after_seconds' => 1,
+ ],
+ ],
+ );
+
+ $delivery = $sender->send(
+ WebhookMessage::new('order.created')
+ ->url('https://hooks.example.test/orders')
+ ->payload(['order_id' => 7]),
+ );
+
+ $requests = $transport->sentRequests();
+ $receiver = Webhook::receiverFromResolvedConfig(
+ 'whsec_resolved',
+ [
+ 'max_age_seconds' => 300,
+ 'replay' => [
+ 'enabled' => true,
+ 'ttl_seconds' => 60,
+ 'namespace' => 'resolved',
+ ],
+ ],
+ new InMemoryWebhookReplayStore(),
+ );
+
+ expect($delivery->result->successful)->toBeTrue()
+ ->and($requests)->toHaveCount(2)
+ ->and($requests[0]->headers->get(WebhookHeaders::SIGNATURE))->not->toBeNull()
+ ->and($receiver)->toBeInstanceOf(\Infocyph\TalkingBytes\Webhook\WebhookReceiver::class);
+});
diff --git a/tests/RuntimeIsolationAndCancellationTest.php b/tests/RuntimeIsolationAndCancellationTest.php
new file mode 100644
index 0000000..2d4ef71
--- /dev/null
+++ b/tests/RuntimeIsolationAndCancellationTest.php
@@ -0,0 +1,130 @@
+parse($raw);
+}
+
+it('keeps injected runtime events independent from the compatibility static bus', function (): void {
+ $globalEvents = [];
+ $localEvents = [];
+
+ CommunicationEventBus::listen(static function (string $event) use (&$globalEvents): void {
+ $globalEvents[] = $event;
+ });
+
+ try {
+ $parser = new BounceParser(events: new CallableEventDispatcher(
+ static function (string $event) use (&$localEvents): void {
+ $localEvents[] = $event;
+ },
+ ));
+
+ $report = $parser->parse(runtimeIsolationBounceEmail());
+
+ expect($report?->type)->toBe(BounceType::MailboxFull)
+ ->and($localEvents)->toBe(['bounce.detected'])
+ ->and($globalEvents)->toBe([]);
+ } finally {
+ CommunicationEventBus::listen(null);
+ }
+});
+
+it('keeps injected event graphs isolated when Fibers interleave', function (): void {
+ $eventsA = [];
+ $eventsB = [];
+ $email = runtimeIsolationBounceEmail();
+
+ $parserA = new BounceParser(events: new CallableEventDispatcher(
+ static function (string $event) use (&$eventsA): void {
+ $eventsA[] = $event;
+ \Fiber::suspend();
+ },
+ ));
+ $parserB = new BounceParser(events: new CallableEventDispatcher(
+ static function (string $event) use (&$eventsB): void {
+ $eventsB[] = $event;
+ },
+ ));
+
+ $fiber = new \Fiber(static fn() => $parserA->parse($email));
+ $fiber->start();
+
+ expect($fiber->isSuspended())->toBeTrue();
+
+ $reportB = $parserB->parse($email);
+ $fiber->resume();
+
+ expect($reportB?->type)->toBe(BounceType::MailboxFull)
+ ->and($fiber->isTerminated())->toBeTrue()
+ ->and($eventsA)->toBe(['bounce.detected'])
+ ->and($eventsB)->toBe(['bounce.detected']);
+});
+
+it('interrupts retry waiting when cancellation is requested', function (): void {
+ $attempts = 0;
+ $sleptMicroseconds = [];
+ $sleeper = new Sleeper(static function (int $microseconds) use (&$sleptMicroseconds): void {
+ $sleptMicroseconds[] = $microseconds;
+ });
+ $cancellation = new CancellationSignal(static function () use (&$attempts): bool {
+ return $attempts >= 1;
+ });
+
+ $result = RetryExecutor::run(
+ new FixedDelayRetryPolicy(3, 250),
+ static function () use (&$attempts): CommunicationResult {
+ $attempts++;
+
+ return CommunicationResult::failure('temporary', 503);
+ },
+ $sleeper,
+ $cancellation,
+ );
+
+ expect($result->successful)->toBeFalse()
+ ->and($result->metadata['cancelled'] ?? false)->toBeTrue()
+ ->and($result->metadata['attempts'] ?? null)->toBe(1)
+ ->and($attempts)->toBe(1)
+ ->and($sleptMicroseconds)->toBe([]);
+});
+
+it('checks cancellation between bounded sleep slices', function (): void {
+ $checks = 0;
+ $slices = [];
+ $sleeper = new Sleeper(static function (int $microseconds) use (&$slices): void {
+ $slices[] = $microseconds;
+ });
+ $cancellation = new CancellationSignal(static function () use (&$checks): bool {
+ $checks++;
+
+ return $checks >= 3;
+ });
+
+ $completed = $sleeper->millisecondsInterruptibly(500, $cancellation, 50);
+
+ expect($completed)->toBeFalse()
+ ->and($slices)->toBe([50_000, 50_000]);
+});
diff --git a/tests/RuntimeSoakTest.php b/tests/RuntimeSoakTest.php
new file mode 100644
index 0000000..5276e45
--- /dev/null
+++ b/tests/RuntimeSoakTest.php
@@ -0,0 +1,56 @@
+ new GrpcResponse(
+ GrpcStatus::Ok,
+ $request->message,
+ ),
+ );
+
+ if ($iteration % 25 === 0) {
+ $references[] = WeakReference::create($http);
+ $references[] = WeakReference::create($email);
+ $references[] = WeakReference::create($replay);
+ $references[] = WeakReference::create($grpc);
+ }
+
+ unset($http, $email, $replay, $grpc);
+ }
+
+ gc_collect_cycles();
+
+ foreach ($references as $reference) {
+ expect($reference->get())->toBeNull();
+ }
+});
+
+it('starts mutable test and replay state clean on every new graph', function (): void {
+ for ($iteration = 0; $iteration < 250; $iteration++) {
+ $transport = new FakeHttpTransport();
+ $client = HttpClient::using($transport);
+ $replay = new InMemoryWebhookReplayStore(2);
+
+ expect($transport->sentRequests())->toBe([])
+ ->and($replay->claim('soak', 'delivery', 60))->toBeTrue();
+
+ unset($client, $transport, $replay);
+ }
+});
diff --git a/tests/TransportProcessCoverageTest.php b/tests/TransportProcessCoverageTest.php
index 374978b..9e85b8a 100644
--- a/tests/TransportProcessCoverageTest.php
+++ b/tests/TransportProcessCoverageTest.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
use Infocyph\TalkingBytes\Email\Config\SendmailConfig;
use Infocyph\TalkingBytes\Email\Config\SpoolConfig;
use Infocyph\TalkingBytes\Email\EmailMessage;
@@ -21,6 +22,25 @@
expect($result->error)->toContain('simulated failure');
});
+it('cancels a running sendmail process cooperatively', function (): void {
+ $script = createSendmailTestScript();
+ $checks = 0;
+ $cancellation = CancellationSignal::fromCallable(static function () use (&$checks): bool {
+ $checks++;
+
+ return $checks > 1;
+ });
+ $transport = new SendmailTransport(
+ new SendmailConfig($script, ['sleep', '5'], 10),
+ cancellation: $cancellation,
+ );
+
+ $result = $transport->send(testMessage());
+
+ expect($result->successful)->toBeFalse();
+ expect($result->error)->toContain('cancelled');
+});
+
it('fails sendmail transport on timeout', function (): void {
$script = createSendmailTestScript();
$transport = new SendmailTransport(new SendmailConfig($script, ['sleep', '2'], 1));
diff --git a/tests/WebhookEventRedactionTest.php b/tests/WebhookEventRedactionTest.php
index bf9cbb4..f9e49da 100644
--- a/tests/WebhookEventRedactionTest.php
+++ b/tests/WebhookEventRedactionTest.php
@@ -37,7 +37,13 @@
$signatureHeader = (string) $sent[0]->headers->get(WebhookHeaders::SIGNATURE);
// Trigger verifier events as well.
- Webhook::verifier($secret, events: $dispatcher)->verifyResult($rawPayload, $signatureHeader);
+ $verified = Webhook::verifier($secret, events: $dispatcher)->verifyResult(
+ $rawPayload,
+ $signatureHeader,
+ event: (string) $sent[0]->headers->get(WebhookHeaders::EVENT),
+ deliveryId: (string) $sent[0]->headers->get(WebhookHeaders::DELIVERY),
+ );
+ expect($verified->valid)->toBeTrue();
Webhook::verifier($secret, events: $dispatcher)->verifyResult($rawPayload, 't=1,v1=not-a-real-signature');
expect($delivery->result->successful)->toBeTrue()
diff --git a/tests/WebhookReceiverTest.php b/tests/WebhookReceiverTest.php
index 1f2dbd5..6b750ac 100644
--- a/tests/WebhookReceiverTest.php
+++ b/tests/WebhookReceiverTest.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use Infocyph\TalkingBytes\Webhook\Contracts\WebhookReplayStore;
use Infocyph\TalkingBytes\Webhook\Replay\InMemoryWebhookReplayStore;
use Infocyph\TalkingBytes\Webhook\Testing\WebhookTestFactory;
use Infocyph\TalkingBytes\Webhook\Webhook;
@@ -40,7 +41,7 @@
$invalidJson = '{';
$invalidJsonHeaders = $headers;
$invalidJsonHeaders['X-TB-Signature'] = (new WebhookSignature('whsec_test'))
- ->buildHeader($invalidJson, (int) $headers['X-TB-Timestamp']);
+ ->buildHeader($invalidJson, (int) $headers['X-TB-Timestamp'], $headers['X-TB-Event'], $headers['X-TB-Delivery']);
expect(fn() => $receiver->receive($invalidJson, $invalidJsonHeaders))
->toThrow(InvalidArgumentException::class, 'Webhook payload must be valid JSON.');
@@ -98,3 +99,125 @@
expect(fn() => $receiver->receive($payload, $spacedEvent))
->toThrow(InvalidArgumentException::class, 'surrounding whitespace');
});
+
+
+it('claims each replay identity only once and validates replay inputs', function (): void {
+ $store = new InMemoryWebhookReplayStore();
+
+ $claims = [
+ $store->claim('tenant-a', 'evt_contention', 60),
+ $store->claim('tenant-a', 'evt_contention', 60),
+ ];
+
+ expect(array_values(array_filter($claims)))->toHaveCount(1);
+ expect(fn() => $store->claim('tenant-a', 'evt_ttl', 0))
+ ->toThrow(InvalidArgumentException::class, 'TTL must be greater than zero');
+
+ $receiver = (new WebhookReceiver(Webhook::verifier('whsec_test')))
+ ->withReplayStore($store, 60);
+
+ expect(fn() => $receiver->withReplayStore($store, 60, ''))
+ ->toThrow(InvalidArgumentException::class, 'namespace must not be empty');
+});
+
+it('verifies webhook signatures before touching replay state', function (): void {
+ [$payload, $headers] = WebhookTestFactory::signedJson(
+ secret: 'whsec_test',
+ event: 'order.created',
+ payload: ['id' => 2],
+ deliveryId: 'evt_verify_first',
+ );
+
+ $store = new class implements WebhookReplayStore {
+ public int $claims = 0;
+
+ public function claim(string $namespace, string $deliveryId, int $ttlSeconds): bool
+ {
+ unset($namespace, $deliveryId, $ttlSeconds);
+ $this->claims++;
+
+ return true;
+ }
+ };
+
+ $receiver = (new WebhookReceiver(Webhook::verifier('whsec_test')))
+ ->withReplayStore($store, 60);
+
+ $headers['X-TB-Signature'] = 't=1,v1=invalid';
+
+ expect(fn() => $receiver->receive($payload, $headers))
+ ->toThrow(RuntimeException::class, 'Webhook verification failed');
+ expect($store->claims)->toBe(0);
+});
+
+it('fails closed when the replay backend cannot claim a verified delivery', function (): void {
+ [$payload, $headers] = WebhookTestFactory::signedJson(
+ secret: 'whsec_test',
+ event: 'order.created',
+ payload: ['id' => 3],
+ deliveryId: 'evt_backend_failure',
+ );
+
+ $store = new class implements WebhookReplayStore {
+ public function claim(string $namespace, string $deliveryId, int $ttlSeconds): bool
+ {
+ unset($namespace, $deliveryId, $ttlSeconds);
+
+ throw new RuntimeException('replay backend unavailable');
+ }
+ };
+
+ $receiver = (new WebhookReceiver(Webhook::verifier('whsec_test')))
+ ->withReplayStore($store, 60);
+
+ expect(fn() => $receiver->receive($payload, $headers))
+ ->toThrow(RuntimeException::class, 'replay backend unavailable');
+});
+
+it('authenticates delivery identity and event before claiming replay state', function (): void {
+ [$payload, $headers] = WebhookTestFactory::signedJson('secret', 'order.created', ['id' => 1], 'original');
+ $receiver = Webhook::receiver('secret')->withReplayStore(new InMemoryWebhookReplayStore());
+
+ foreach (['X-TB-Delivery' => 'altered', 'X-TB-Event' => 'order.refunded'] as $name => $value) {
+ $tampered = $headers;
+ $tampered[$name] = $value;
+ expect(fn() => $receiver->receive($payload, $tampered))->toThrow(RuntimeException::class, 'signature_mismatch');
+ }
+
+ expect($receiver->receive($payload, $headers)->deliveryId)->toBe('original');
+ expect(fn() => $receiver->receive($payload, $headers))->toThrow(RuntimeException::class, 'already been processed');
+ $headers['X-TB-Delivery'] = 'replayed';
+ expect(fn() => $receiver->receive($payload, $headers))->toThrow(RuntimeException::class, 'signature_mismatch');
+});
+
+it('rejects legacy signature downgrade even alongside an invalid bound signature', function (): void {
+ [$payload, $headers] = WebhookTestFactory::signedJson('secret', 'order.created', ['id' => 1]);
+ $legacy = (new WebhookSignature('secret'))->buildHeader($payload, (int) $headers['X-TB-Timestamp']);
+ $receiver = Webhook::receiver('secret');
+ $headers['X-TB-Signature'] = $legacy;
+ expect(fn() => $receiver->receive($payload, $headers))->toThrow(RuntimeException::class, 'malformed_signature');
+ $headers['X-TB-Signature'] .= ',v2=' . str_repeat('0', 64);
+ expect(fn() => $receiver->receive($payload, $headers))->toThrow(RuntimeException::class, 'signature_mismatch');
+});
+
+it('accepts bound signatures across secret rotation and rejects payload and timestamp tampering', function (): void {
+ [$payload, $headers] = WebhookTestFactory::signedJson('old-secret', 'order.created', ['id' => 1]);
+ $receiver = new WebhookReceiver(new \Infocyph\TalkingBytes\Webhook\WebhookVerifier(['new-secret', 'old-secret']));
+ expect($receiver->receive($payload, $headers)->payload)->toBe(['id' => 1]);
+ expect(fn() => $receiver->receive('{"id":2}', $headers))->toThrow(RuntimeException::class, 'signature_mismatch');
+ $headers['X-TB-Timestamp'] = (string) ((int) $headers['X-TB-Timestamp'] + 1);
+ expect(fn() => $receiver->receive($payload, $headers))->toThrow(RuntimeException::class, 'signature_mismatch');
+});
+
+it('receives the signed request produced by the native sender', function (): void {
+ $transport = new \Infocyph\TalkingBytes\Http\Testing\FakeHttpTransport();
+ Webhook::sender(\Infocyph\TalkingBytes\Http\HttpClient::using($transport))->withSecret('secret')->send(
+ \Infocyph\TalkingBytes\Webhook\WebhookMessage::event('order.created')->url('https://example.test/hook')->payload(['id' => 1]),
+ );
+ $request = $transport->sentRequests()[0];
+ $headers = [];
+ foreach (['X-TB-Event', 'X-TB-Delivery', 'X-TB-Timestamp', 'X-TB-Signature'] as $name) {
+ $headers[$name] = (string) $request->headers->get($name);
+ }
+ expect(Webhook::receiver('secret')->receive('{"id":1}', $headers)->event)->toBe('order.created');
+});
diff --git a/tests/WebhookSenderTest.php b/tests/WebhookSenderTest.php
index fc36b18..2f63cb4 100644
--- a/tests/WebhookSenderTest.php
+++ b/tests/WebhookSenderTest.php
@@ -5,6 +5,8 @@
use Infocyph\TalkingBytes\Core\Event\CommunicationEventBus;
use Infocyph\TalkingBytes\Core\Event\CallableEventDispatcher;
use Infocyph\TalkingBytes\Core\Result\CommunicationResult;
+use Infocyph\TalkingBytes\Core\Support\CancellationSignal;
+use Infocyph\TalkingBytes\Core\Support\Sleeper;
use Infocyph\TalkingBytes\Http\HttpClient;
use Infocyph\TalkingBytes\Http\HttpResponse;
use Infocyph\TalkingBytes\Http\Testing\SequenceHttpTransport;
@@ -112,6 +114,65 @@
->and((string) ($events[1]['payload']['signature'] ?? ''))->toBe('');
});
+
+it('stops before the first webhook attempt when cancellation is already requested', function (): void {
+ $transport = new SequenceHttpTransport([
+ CommunicationResult::success(200, new HttpResponse(200, '{"ok":true}')),
+ ]);
+ $sender = WebhookSender::usingHttp(HttpClient::using($transport))
+ ->withCancellation(CancellationSignal::fromCallable(static fn(): bool => true));
+
+ $delivery = $sender->send(
+ WebhookMessage::new('order.created')
+ ->payload(['order_id' => 1001])
+ ->url('https://hooks.example.test/orders'),
+ );
+
+ expect($transport->sentRequests())->toBe([])
+ ->and($delivery->result->successful)->toBeFalse()
+ ->and($delivery->result->metadata['cancelled'] ?? false)->toBeTrue()
+ ->and($delivery->delivery?->attempts)->toBe(0)
+ ->and($delivery->delivery?->metadata['cancelled'] ?? false)->toBeTrue();
+});
+
+it('interrupts webhook retry waiting when cancellation is requested', function (): void {
+ $cancelled = false;
+ $transport = new SequenceHttpTransport([
+ CommunicationResult::failure(
+ 'HTTP request failed with status code 500.',
+ 500,
+ new HttpResponse(500, '{"error":true}'),
+ ),
+ CommunicationResult::success(200, new HttpResponse(200, '{"ok":true}')),
+ ]);
+ $sleeper = new Sleeper(static function () use (&$cancelled): void {
+ $cancelled = true;
+ });
+ $signal = CancellationSignal::fromCallable(static function () use (&$cancelled): bool {
+ return $cancelled;
+ });
+ $sender = WebhookSender::usingHttpWithRetryProfile(
+ HttpClient::using($transport),
+ attempts: 2,
+ baseDelayMs: 250,
+ sleeper: $sleeper,
+ cancellation: $signal,
+ );
+
+ $delivery = $sender->send(
+ WebhookMessage::new('order.created')
+ ->payload(['order_id' => 1001])
+ ->url('https://hooks.example.test/orders'),
+ );
+
+ expect($transport->sentRequests())->toHaveCount(1)
+ ->and($delivery->result->successful)->toBeFalse()
+ ->and($delivery->result->statusCode)->toBe(500)
+ ->and($delivery->result->metadata['cancelled'] ?? false)->toBeTrue()
+ ->and($delivery->delivery?->attempts)->toBe(1)
+ ->and($delivery->delivery?->metadata['cancelled'] ?? false)->toBeTrue();
+});
+
it('rejects overriding reserved webhook headers', function (): void {
expect(fn() => WebhookMessage::new('order.created')->header('X-TB-Event', 'override'))
->toThrow(InvalidArgumentException::class);