From 69a2d8f8c019358999a9fd48bf5edf42f51a914d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 14:57:36 +0600 Subject: [PATCH 01/51] docs(plan): add TalkingBytes 2.1 Foundation integration hardening plan --- ...1-foundation-integration-hardening-plan.md | 634 ++++++++++++++++++ 1 file changed, 634 insertions(+) create mode 100644 docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md diff --git a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md new file mode 100644 index 0000000..3f694b8 --- /dev/null +++ b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md @@ -0,0 +1,634 @@ +# TalkingBytes 2.1 — Foundation 3 Integration Hardening Plan + +## Status + +Target release: **TalkingBytes 2.1.x** + +Baseline: + +- branch: main +- tag: 2.0.0 +- baseline commit: 86d0e9dde8124ddeacea8ba7f81911af584b879b +- primary consumer: Foundation 3 runtime plan point 26.9 +- plan state: **PLANNED** + +TalkingBytes 2.0 already established the intended protocol architecture. This is a focused 2.1 integration-hardening release, not another architectural rewrite. + +The release goal is to make the existing HTTP, Email, Webhook and gRPC surfaces safe and explicit for persistent workers, Fibers and framework integration while keeping all protocol mechanics owned by TalkingBytes. + +--- + +## 1. Release Policy + +### 1.1 Compatibility + +TalkingBytes 2.1 should remain additive and minor-release compatible wherever practical. + +Priority order: + +1. correctness +2. security +3. runtime isolation +4. protocol ownership +5. performance +6. scalability +7. API clarity +8. compatibility + +If a security or correctness defect cannot be corrected compatibly, make the smallest necessary correction and document it explicitly. + +### 1.2 Runtime floor + +Keep PHP >= 8.4. + +Keep infocyph/phpforge dev-main@dev in require-dev. + +### 1.3 Dependency policy + +TalkingBytes must remain framework-agnostic and lightweight. + +Do not add Foundation, InterMix, CacheLayer, DBLayer, Omnibus, Pathwise or another Infocyph runtime library merely to simplify host integration. + +Optional integrations remain behind native contracts and adapters. + +--- + +## 2. Ownership Boundary + +### TalkingBytes owns + +- outbound HTTP request preparation and transport; +- redirect processing, HTTP streaming, retry and transport security; +- HTTP authentication, signing and cookie mechanics; +- inbound and outbound email protocol mechanics; +- SMTP, sendmail, PHP mail and spool transports; +- IMAP and POP3 mailbox behavior; +- MIME parsing, transfer decoding, charset decoding and attachment extraction; +- DKIM, authentication-result parsing and bounce classification; +- webhook signing, verification, timestamp validation and replay-store contract; +- gRPC request/response models; +- gRPC metadata, deadlines, status mapping, retry and stream mechanics; +- generated/native gRPC adapters; +- protocol fakes, assertion helpers, events and native benchmarks. + +### Foundation owns + +- named application profiles; +- capability selection and dependency activation; +- DI lifetime selection; +- application secret and configuration mapping; +- application path policy; +- CacheLayer-backed webhook replay-store implementation; +- worker heartbeat, stop and release-generation lifecycle; +- application service/handler mapping; +- application logging/audit policy; +- direct-versus-Foundation bridge benchmarks. + +### Explicit non-ownership + +TalkingBytes must not become: + +- a Foundation-specific package; +- an HTTP application framework/server; +- a cache/database abstraction; +- a queue or worker supervisor; +- a home-grown gRPC wire stack; +- a process manager. + +--- + +## 3. Verified Baseline Findings + +### 3.1 Process-global event state remains in runtime paths + +CommunicationEventBus stores a static dispatcher. + +The current docs already describe this static bus as a compatibility adapter and recommend injected EventDispatcher objects for long-running workers. However, production runtime paths still call the static bus directly, including: + +- Email/Receiver/SpoolEmailReceiver; +- Email/Mailbox/SocketMailboxRuntime; +- Email/Parser/BounceParser. + +This is undesirable for persistent workers and Fiber-interleaved execution because one process-global listener can outlive the logical operation that installed it. + +### 3.2 HTTP graph is immutable, selected collaborators are not + +HttpClient uses immutable fluent composition, but selected collaborators intentionally contain mutable state: + +- CookieJar; +- CircuitBreaker; +- RateLimiter; +- fake/spy transports. + +This is correct functionality, but the lifetime contract is not explicit enough for host frameworks. Sharing such an instance at the wrong scope can leak cookie, breaker, limiter or test state between unrelated requests/jobs. + +### 3.3 Webhook replay abstraction is correctly host-neutral + +WebhookReplayStore::claim(namespace, deliveryId, ttlSeconds) already expresses the right lower-layer requirement: + +- atomic first claim; +- bounded TTL; +- duplicate detection. + +Foundation can implement the contract with CacheLayer without creating a TalkingBytes-to-CacheLayer dependency. + +The contract should be hardened and tested, not replaced. + +### 3.4 Inbound gRPC currently stops at dispatch + +GrpcInboundDispatcher correctly maps normalized inbound requests to application handlers. + +TalkingBytes does not yet expose a clean host-facing accepted-call/runtime boundary that a Foundation worker can drive one cycle at a time. + +Foundation should not recreate gRPC protocol adaptation merely to fit its worker lifecycle. + +### 3.5 Observability redaction is not uniformly strict + +HTTP, webhook and mailbox paths already contain useful redaction, but some gRPC and email event paths can expose raw failure strings. + +Raw exception messages may contain endpoints, metadata or credential-bearing lower-layer text. Runtime events should prefer structured and sanitized failure data. + +### 3.6 Optional capability coldness needs release evidence + +Native gRPC and several mail-related extensions/packages are optional by design. + +The release must explicitly prove that unrelated protocol usage does not eagerly require or initialize optional capabilities. + +--- + +## 4. Workstream 1 — P0 Runtime Event-State Isolation + +### Goal + +Injected EventDispatcher instances become the authoritative runtime observability mechanism. + +CommunicationEventBus remains compatibility-only. + +### Tasks + +- [ ] Add or propagate optional EventDispatcher dependencies through native email factories where required. +- [ ] Convert SpoolEmailReceiver lifecycle events from static bus dispatch to an injected dispatcher. +- [ ] Convert mailbox command events away from direct CommunicationEventBus use. +- [ ] Convert BounceParser event emission away from direct CommunicationEventBus use. +- [ ] Audit every production src/ reference to CommunicationEventBus. +- [ ] Ensure newly created protocol/runtime code never requires process-global event state. +- [ ] Preserve CommunicationEventBus only as a compatibility facade. +- [ ] Keep dispatch best-effort: observer failures must never alter protocol results. +- [ ] Add sequential persistent-runtime tests proving event listeners do not leak between operations. +- [ ] Add Fiber-interleaving tests for execution paths that can overlap. +- [ ] Update events documentation to make injection the primary path. + +### Acceptance + +A normal object graph created through public factory/constructor APIs must not depend on static event state. + +--- + +## 5. Workstream 2 — P0 Mutable-State Lifetime Contracts + +### Goal + +Document and test which objects are immutable, reusable or execution/session state. + +### Required classifications + +| Component | State model | Host expectation | +| --- | --- | --- | +| HttpClientConfig | immutable configuration | reusable | +| HttpClient without mutable collaborators | immutable graph | reusable when policy allows | +| CookieJar | mutable session state | execution/session scoped | +| CircuitBreaker | mutable resilience state | explicit shared/profile scope only | +| RateLimiter | mutable token state | explicit shared/profile scope only | +| WebhookVerifier | immutable secret/policy graph | reusable if secret lifecycle permits | +| WebhookReceiver | immutable graph around replay store | replay-store lifetime dependent | +| GrpcClient | immutable graph over invoker | invoker lifetime dependent | +| GrpcInboundDispatcher | immutable handler graph | reusable if handlers are safe | +| Emailer | immutable graph over transport | transport lifetime dependent | +| mailbox/socket transports | connection/session state | execution/worker owned | +| fakes/spies | mutable test state | test scoped | + +### Tasks + +- [ ] Add the lifetime matrix to architecture/runtime documentation. +- [ ] Prove fluent client operations never mutate previous instances. +- [ ] Prove cookie state exists only when a CookieJar is explicitly attached. +- [ ] Prove separate cookie jars do not cross-contaminate. +- [ ] Prove separate CircuitBreaker instances do not share counters. +- [ ] Prove separate RateLimiter instances do not share tokens. +- [ ] Add Fiber/interleaving tests around mutable collaborators. +- [ ] Keep shared resilience state an explicit host decision. +- [ ] Do not introduce a global resilience registry. +- [ ] Verify auth tokens and credentials are retained only inside explicitly configured client graphs. +- [ ] Ensure fake/spy state has deterministic new-instance/reset behavior. + +--- + +## 6. Workstream 3 — P0 Webhook Replay Hardening + +### Goal + +Keep replay protection protocol-owned but storage-provider-neutral. + +### Tasks + +- [ ] Keep WebhookReplayStore minimal. +- [ ] Document that claim must be atomic across competing processes for production use. +- [ ] Document that replay backend errors must fail closed. +- [ ] Add a contention contract test where only one contender wins the same delivery claim. +- [ ] Add a throwing-store test proving WebhookReceiver does not silently bypass replay protection. +- [ ] Preserve strict positive TTL validation. +- [ ] Preserve bounded namespace and delivery-ID validation. +- [ ] Preserve signature/timestamp verification before replay claim. +- [ ] Preserve replay claim before a verified event is returned to application code. +- [ ] Ensure replay observability never exposes raw secret, signature or body data. +- [ ] Do not add CacheLayer as a TalkingBytes dependency. + +### Foundation handoff + +Foundation continues implementing WebhookReplayStore using CacheLayer atomic setIfAbsent under Foundation's own security cache-key domain. + +--- + +## 7. Workstream 4 — P0 Host-Controlled Inbound gRPC Runtime Bridge + +### Goal + +Allow Foundation or another host to execute inbound gRPC through its own worker lifecycle without reimplementing TalkingBytes protocol adaptation. + +GrpcInboundDispatcher remains the application dispatch boundary. + +TalkingBytes does not become the worker supervisor. + +### Target flow + +native/server runtime +→ TalkingBytes inbound adapter/source +→ accepted gRPC exchange +→ normalized GrpcInboundRequest +→ GrpcInboundDispatcher +→ GrpcInboundResponse +→ native exchange completion + +Exact class names may change while implementing. The ownership boundary must not. + +### Required characteristics + +- [ ] Add a small host-facing contract for obtaining/accepting one inbound gRPC exchange. +- [ ] The accepted exchange exposes a normalized GrpcInboundRequest. +- [ ] TalkingBytes owns mapping GrpcInboundResponse/status/metadata back to the native exchange. +- [ ] Provide a single-cycle or otherwise host-controllable execution API. +- [ ] Allow Foundation to check heartbeat, stop token and release generation between accepted calls. +- [ ] Do not hide an infinite process loop inside TalkingBytes unless cancellation/lifecycle control is explicit. +- [ ] Preserve gRPC method normalization. +- [ ] Preserve metadata mapping. +- [ ] Preserve deadline mapping. +- [ ] Preserve status/error mapping. +- [ ] Add a fake inbound source/exchange for deterministic integration tests. +- [ ] Do not add socket/process supervision. +- [ ] Do not require Foundation or Omnibus. +- [ ] Keep ext-grpc and generated-runtime dependencies optional until their adapters are selected. +- [ ] Document the exact inbound streaming modes actually supported. +- [ ] Do not claim inbound server/client/bidirectional streaming unless native inbound adapters implement them. +- [ ] If inbound streaming is added, keep it incremental, bounded and host-cancellable. + +### Foundation handoff + +Foundation wraps this one-cycle protocol boundary with the existing Foundation worker heartbeat, stop-token and release-generation lifecycle. + +--- + +## 8. Workstream 5 — P1 Persistent-Runtime Email Hardening + +### Goal + +Keep TalkingBytes' full native inbound/outbound email system while making persistent-worker ownership explicit. + +### Tasks + +- [ ] Propagate injected EventDispatcher objects through EmailSenderFactory, EmailReceiverFactory and EmailMailboxFactory where required. +- [ ] Keep Emailer transport composition native to TalkingBytes. +- [ ] Keep SMTP/sendmail/mail/spool behavior native. +- [ ] Keep IMAP/POP3 mailbox behavior native. +- [ ] Keep MIME/parsing/DKIM/bounce behavior native. +- [ ] Define mailbox/socket connection ownership. +- [ ] Define logout/close/shutdown behavior for connection-bearing objects. +- [ ] Ensure one failed mailbox/session cannot poison subsequently constructed instances. +- [ ] Preserve bounded line/message/attachment/parse limits. +- [ ] Preserve spool processing/quarantine behavior. +- [ ] Preserve file locking and safe move semantics. +- [ ] Add sequential persistent-worker tests for sender, receiver and mailbox boundaries. +- [ ] Add Fiber/interleaving tests for stateless parser paths where useful. +- [ ] Prove no static event listener leaks across mail operations. + +### Non-goal + +Do not add Foundation-specific profile/config objects to TalkingBytes. + +--- + +## 9. Workstream 6 — P1 Observability and Secret Redaction + +### Goal + +No default protocol event or log context should expose application secrets. + +### Audit domains + +- HTTP +- Webhook +- gRPC +- Email +- Mailbox + +### Tasks + +- [ ] Never emit raw Authorization credentials. +- [ ] Never emit raw bearer/API tokens. +- [ ] Never emit cookie values. +- [ ] Never emit proxy credentials. +- [ ] Never emit webhook secrets. +- [ ] Never emit raw webhook signatures. +- [ ] Never emit webhook bodies as observability fields. +- [ ] Never emit SMTP/mailbox passwords. +- [ ] Never emit raw authentication commands. +- [ ] Avoid raw gRPC metadata values unless explicitly classified safe. +- [ ] Stop blindly copying raw exception messages into protocol events. +- [ ] Prefer exception class, status/code and bounded sanitized categories. +- [ ] Keep caller-facing CommunicationResult detail useful; observability may deliberately be stricter. +- [ ] Add sentinel-secret tests and assert sentinel values never appear in emitted event/log payloads. +- [ ] Keep redaction overhead bounded on hot paths. + +--- + +## 10. Workstream 7 — P1 Optional Capability Coldness + +### Goal + +Selecting one protocol must not eagerly activate another protocol's optional requirements. + +### Acceptance matrix + +- [ ] HTTP works without ext-grpc and grpc/grpc. +- [ ] Webhook works without native gRPC packages. +- [ ] Basic outbound email does not require IMAP-specific extensions. +- [ ] IMAP/POP3 optional capability checks occur only when those paths are selected. +- [ ] RSA DKIM does not require Sodium. +- [ ] Ed25519 DKIM fails clearly only when selected and Sodium is unavailable. +- [ ] Native/generated gRPC paths fail with actionable messages only when selected. +- [ ] Composer suggest metadata matches real runtime requirements. +- [ ] Documentation matches Composer optional capability metadata. +- [ ] Avoid unrelated extension/class probing on protocol hot paths. + +--- + +## 11. Workstream 8 — P1 Native Benchmark Evidence + +TalkingBytes owns native protocol benchmarks. + +Foundation owns Foundation-bridge comparison benchmarks. + +### HTTP benchmark coverage + +- [ ] immutable client construction; +- [ ] request preparation; +- [ ] fake transport send; +- [ ] cookie-enabled send; +- [ ] retry middleware overhead; +- [ ] rate-limiter overhead; +- [ ] circuit-breaker overhead; +- [ ] repeated-run memory growth. + +### Webhook benchmark coverage + +- [ ] signing; +- [ ] verification; +- [ ] verification plus replay claim; +- [ ] duplicate rejection. + +### gRPC benchmark coverage + +- [ ] unary client dispatch; +- [ ] inbound dispatcher; +- [ ] new host inbound-exchange adapter; +- [ ] retry decision path; +- [ ] generated/native adapter overhead when available; +- [ ] streaming adapter overhead without eager stream materialization. + +### Email benchmark coverage + +- [ ] message preparation; +- [ ] null/fake send; +- [ ] parser; +- [ ] spool receive; +- [ ] deterministic mailbox adapter overhead where practical. + +### Benchmark rules + +- [ ] Do not add Foundation as a benchmark dependency. +- [ ] Separate CPU microbenchmarks from network/disk I/O. +- [ ] Record peak memory where meaningful. +- [ ] Add repeated-run checks for unexpected memory/state growth. +- [ ] Preserve clear ownership attribution. + +--- + +## 12. Workstream 9 — Documentation and Release Metadata + +- [ ] Update architecture docs with the lifetime/ownership matrix. +- [ ] Update events docs: injected dispatcher primary, static bus compatibility-only. +- [ ] Update webhook replay docs with atomic and fail-closed requirements. +- [ ] Update gRPC inbound docs for the host-runtime bridge. +- [ ] Update email docs for persistent-worker connection ownership. +- [ ] Update security docs with secret/redaction guarantees. +- [ ] Update performance docs with persistent-runtime guidance. +- [ ] Update testing docs with isolation and fake inbound-runtime examples. +- [ ] Update release checklist with static-state, optional-cold and secret-sentinel gates. +- [ ] Keep README examples aligned with the released API. +- [ ] Keep Composer requirements/suggestions synchronized with actual runtime behavior. + +--- + +## 13. Likely File Touch Map + +This is a planning map, not a requirement to modify every listed file. + +### Core/events + +- src/Core/Event/CommunicationEventBus.php +- src/Core/Event/EventDispatcher.php +- src/Core/Event/BestEffortEventDispatcher.php +- docs/events.rst + +### Email + +- src/Email/Email.php +- src/Email/EmailSenderFactory.php +- src/Email/EmailReceiverFactory.php +- src/Email/EmailMailboxFactory.php +- src/Email/Receiver/SpoolEmailReceiver.php +- src/Email/Mailbox/SocketMailboxRuntime.php +- src/Email/Parser/BounceParser.php +- relevant Email/Mailbox/Bounce tests + +### HTTP/state + +- src/Http/HttpClient.php +- src/Http/Cookie/CookieJar.php +- src/Http/Middleware/* +- src/Resilience/CircuitBreaker.php +- src/Resilience/RateLimiter.php +- relevant HTTP/resilience tests + +### Webhook + +- src/Webhook/Contracts/WebhookReplayStore.php +- src/Webhook/WebhookReceiver.php +- src/Webhook/WebhookVerifier.php +- src/Webhook/WebhookSender.php +- tests/Webhook* +- docs/webhook/* + +### gRPC + +- src/Grpc/GrpcInboundDispatcher.php +- src/Grpc/Receiver/* +- src/Grpc/Native/* +- src/Grpc/Testing/* +- tests/Grpc* +- docs/grpc/* + +### Benchmarks/release + +- benchmarks/HttpBench.php +- benchmarks/WebhookBench.php +- benchmarks/GrpcBench.php +- benchmarks/EmailBench.php +- benchmarks/LargeEmailBench.php +- benchmarks/ResilienceBench.php +- docs/performance.rst +- docs/release-checklist.rst +- composer.json + +--- + +## 14. Execution Order + +### Batch 1 — Runtime-state cleanup + +- remove static-event dependency from primary runtime paths; +- propagate EventDispatcher injection; +- add persistent-runtime and Fiber isolation coverage; +- document lifetime semantics. + +### Batch 2 — Secret and observability hardening + +- audit protocol events/logs; +- remove raw secret/failure leakage; +- add secret-sentinel tests. + +### Batch 3 — Webhook replay acceptance + +- lock the atomic/fail-closed replay contract; +- add concurrency/error acceptance coverage; +- preserve storage-provider neutrality. + +### Batch 4 — Inbound gRPC hosting boundary + +- introduce accepted-call/source contract; +- connect it to GrpcInboundDispatcher; +- add fake source/exchange; +- prove host-controlled one-cycle execution; +- prove deadline/status/metadata mapping. + +### Batch 5 — Email persistent-runtime acceptance + +- complete event injection in mail paths; +- test connection/session ownership; +- retain native send/receive/parser behavior. + +### Batch 6 — Optional cold graphs + +- test protocols with unrelated optional dependencies absent; +- align errors, docs and Composer metadata. + +### Batch 7 — Native benchmarks and docs + +- expand native benchmark evidence; +- update architecture/security/testing/performance/release documentation. + +### Batch 8 — Exact-head release gate + +Run the complete PHPForge matrix and tag only after the exact final head passes every closure gate. + +--- + +## 15. Foundation 3 Handoff + +After TalkingBytes 2.1 is released: + +- [ ] Foundation raises its communication floor from ^2.0 to ^2.1 only if the new host-facing APIs are required. +- [ ] Foundation keeps CommunicationProfiles as application composition only. +- [ ] Foundation keeps HTTP clients scoped when cookie/resilience state can be mutable. +- [ ] Foundation does not duplicate TalkingBytes HTTP retry, signing, cookie or transport mechanics. +- [ ] Foundation keeps the CacheLayer replay implementation in Foundation. +- [ ] TalkingBytes keeps only the replay contract. +- [ ] Foundation selects webhook verifier/receiver lifetimes according to actual state. +- [ ] Foundation routes inbound gRPC through its existing worker heartbeat/stop/release lifecycle using the new TalkingBytes host-runtime boundary. +- [ ] Foundation does not implement the gRPC network stack. +- [ ] Foundation continues using native TalkingBytes sender/receiver/mailbox APIs. +- [ ] Foundation proves communication secrets are absent from generated metadata, cache keys and logs. +- [ ] Foundation adds direct-TalkingBytes-versus-Foundation bridge benchmark attribution. +- [ ] Foundation closes runtime-plan point 26.9 only on the exact-head PHPForge matrix. + +--- + +## 16. Completion Gate + +TalkingBytes 2.1 is complete only when all of the following are true: + +- [ ] no primary runtime path depends on process-global CommunicationEventBus state; +- [ ] mutable HTTP/resilience/session state has explicit lifetime semantics; +- [ ] sequential and Fiber-interleaved state-isolation tests pass; +- [ ] webhook replay is documented and tested as atomic and fail-closed; +- [ ] no CacheLayer or Foundation runtime dependency was introduced; +- [ ] inbound gRPC has a host-controllable adapter boundary suitable for Foundation workers; +- [ ] gRPC network/process ownership remains correctly outside TalkingBytes; +- [ ] native inbound/outbound email APIs remain authoritative; +- [ ] secret-sentinel tests pass across HTTP, webhook, gRPC, email and mailbox events/logging; +- [ ] unrelated optional capabilities remain cold until selected; +- [ ] native protocol benchmark evidence is recorded with correct attribution; +- [ ] PHPForge QA/static/security gates pass on the supported PHP/dependency matrix; +- [ ] documentation builds warning-free; +- [ ] release metadata/examples match the final API; +- [ ] the exact final commit is tagged only after the complete matrix is green. + +--- + +## 17. Explicitly Out of Scope + +Do not use 2.1 to add: + +- another universal communication envelope; +- Foundation-specific service providers/configuration; +- CacheLayer, DBLayer or Omnibus as TalkingBytes runtime dependencies; +- an HTTP application server/framework; +- a general worker supervisor; +- a custom gRPC wire implementation; +- application-specific profile ownership; +- another major-version-scale redesign; +- unrelated feature expansion that does not improve TalkingBytes protocol correctness or the Foundation 3 integration boundary. + +--- + +## 18. Immediate Starting Point + +Start with **Batch 1 — Runtime-state cleanup**. + +First objective: + +1. remove direct CommunicationEventBus dependence from SpoolEmailReceiver, mailbox runtime paths and BounceParser; +2. propagate injected EventDispatcher objects through existing native factories; +3. add sequential persistent-runtime and Fiber isolation tests; +4. keep the static bus only as compatibility behavior. + +Do not modify Foundation during this first batch. TalkingBytes should first expose the clean lower-layer behavior; Foundation should consume the released result afterward. From 11ba8d5887ca7ad86a7c9a91d592d571e5171329 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 15:17:42 +0600 Subject: [PATCH 02/51] docs: expand TalkingBytes 2.1 hardening plan after full rescan --- ...1-foundation-integration-hardening-plan.md | 956 +++++++++++++----- 1 file changed, 683 insertions(+), 273 deletions(-) diff --git a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md index 3f694b8..ab50fde 100644 --- a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md +++ b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md @@ -1,20 +1,22 @@ -# TalkingBytes 2.1 — Foundation 3 Integration Hardening Plan +# TalkingBytes 2.1 — Foundation 3 Integration Hardening & Runtime Ownership Plan ## Status -Target release: **TalkingBytes 2.1.x** +Recommended target release: **TalkingBytes 2.1.x** Baseline: -- branch: main -- tag: 2.0.0 +- working branch: talkingbytes-2.1/foundation-integration-hardening +- implementation baseline branch: main +- released baseline: 2.0.0 - baseline commit: 86d0e9dde8124ddeacea8ba7f81911af584b879b +- current planning head before this revision: 69a2d8f8c019358999a9fd48bf5edf42f51a914d - primary consumer: Foundation 3 runtime plan point 26.9 -- plan state: **PLANNED** +- plan state: **PLANNED / RESCANNED** -TalkingBytes 2.0 already established the intended protocol architecture. This is a focused 2.1 integration-hardening release, not another architectural rewrite. +TalkingBytes 2.0 already established the intended protocol architecture. The 2.1 release should harden that architecture for persistent workers, Fibers, framework integration and high-throughput use while moving protocol composition out of Foundation where it currently leaks upward. -The release goal is to make the existing HTTP, Email, Webhook and gRPC surfaces safe and explicit for persistent workers, Fibers and framework integration while keeping all protocol mechanics owned by TalkingBytes. +The default release remains 2.1 because the required work can be implemented additively. Promote the release to the next major only if implementation proves that a public removal, incompatible constructor/signature change, or incompatible configuration semantic is genuinely required. Do not create a major release merely to permit cleanup that can remain additive. --- @@ -22,7 +24,7 @@ The release goal is to make the existing HTTP, Email, Webhook and gRPC surfaces ### 1.1 Compatibility -TalkingBytes 2.1 should remain additive and minor-release compatible wherever practical. +Keep 2.1 additive and minor-release compatible wherever practical. Priority order: @@ -35,7 +37,7 @@ Priority order: 7. API clarity 8. compatibility -If a security or correctness defect cannot be corrected compatibly, make the smallest necessary correction and document it explicitly. +If a correctness or security defect cannot be fixed compatibly, make the smallest necessary correction, document it, and use the major-version decision gate in Section 18. ### 1.2 Runtime floor @@ -51,6 +53,17 @@ Do not add Foundation, InterMix, CacheLayer, DBLayer, Omnibus, Pathwise or anoth Optional integrations remain behind native contracts and adapters. +### 1.4 Extension policy + +Do not make pcntl or posix required extensions. + +- posix may be used opportunistically for safer Unix child-process group termination where available. +- pcntl must not be used implicitly by normal HTTP, email, webhook or gRPC object graphs. +- do not add fork-based protocol concurrency. +- do not install process-global signal handlers from ordinary protocol clients/transports. +- if a standalone CLI signal-to-cancellation adapter is eventually added, it must be explicit opt-in, restore previous handlers, remain optional and be independently tested. +- Foundation keeps ownership of its worker/supervisor signal lifecycle. + --- ## 2. Ownership Boundary @@ -58,31 +71,37 @@ Optional integrations remain behind native contracts and adapters. ### TalkingBytes owns - outbound HTTP request preparation and transport; -- redirect processing, HTTP streaming, retry and transport security; -- HTTP authentication, signing and cookie mechanics; +- redirect processing, HTTP streaming, concurrent request mechanics, retry and transport security; +- HTTP authentication, signing, idempotency and cookie mechanics; +- protocol-native client/factory composition from already-resolved values; - inbound and outbound email protocol mechanics; -- SMTP, sendmail, PHP mail and spool transports; +- SMTP, sendmail, PHP mail, spool and logging transports; - IMAP and POP3 mailbox behavior; - MIME parsing, transfer decoding, charset decoding and attachment extraction; - DKIM, authentication-result parsing and bounce classification; -- webhook signing, verification, timestamp validation and replay-store contract; +- webhook signing, verification, timestamp validation, retry semantics and replay-store contract; - gRPC request/response models; - gRPC metadata, deadlines, status mapping, retry and stream mechanics; - generated/native gRPC adapters; +- host-controllable inbound gRPC exchange adaptation; +- protocol-level cancellation checks where an operation can wait, retry, stream or poll; - protocol fakes, assertion helpers, events and native benchmarks. ### Foundation owns -- named application profiles; +- named application profile lookup and default profile selection; - capability selection and dependency activation; - DI lifetime selection; -- application secret and configuration mapping; -- application path policy; +- application secret resolution and production policy; +- application path resolution; +- application-level configuration source mapping; - CacheLayer-backed webhook replay-store implementation; - worker heartbeat, stop and release-generation lifecycle; -- application service/handler mapping; +- process supervision for Foundation console/scheduler/application commands; +- application service/handler lookup and DI mapping; - application logging/audit policy; -- direct-versus-Foundation bridge benchmarks. +- application notification/template mapping; +- direct-versus-TalkingBytes bridge benchmarks. ### Explicit non-ownership @@ -92,8 +111,26 @@ TalkingBytes must not become: - an HTTP application framework/server; - a cache/database abstraction; - a queue or worker supervisor; +- a generic application process manager; - a home-grown gRPC wire stack; -- a process manager. +- a service container; +- a named application-profile repository. + +### Foundation code that should remain in Foundation + +Do not move these merely to make Foundation smaller: + +- CacheLayerWebhookReplayStore; +- production TLS policy and deployment-specific security policy; +- change-me/default-secret rejection; +- named profile lookup; +- application path expansion; +- DI service-to-gRPC-handler resolution; +- auth notification mapping/templates; +- notification recipient routing; +- Foundation ProcessRunner used by console, module and scheduler execution. + +TalkingBytes should only absorb the lower-level protocol composition currently duplicated around those policies. --- @@ -103,92 +140,230 @@ TalkingBytes must not become: CommunicationEventBus stores a static dispatcher. -The current docs already describe this static bus as a compatibility adapter and recommend injected EventDispatcher objects for long-running workers. However, production runtime paths still call the static bus directly, including: +Production runtime paths still call it directly, including: - Email/Receiver/SpoolEmailReceiver; - Email/Mailbox/SocketMailboxRuntime; - Email/Parser/BounceParser. -This is undesirable for persistent workers and Fiber-interleaved execution because one process-global listener can outlive the logical operation that installed it. +The static bus is already documented as compatibility-only, but these runtime paths still make global state part of normal execution. -### 3.2 HTTP graph is immutable, selected collaborators are not +### 3.2 HTTP graph is immutable, selected collaborators are mutable -HttpClient uses immutable fluent composition, but selected collaborators intentionally contain mutable state: +HttpClient is fluent/immutable, but these collaborators intentionally retain state: - CookieJar; - CircuitBreaker; - RateLimiter; - fake/spy transports. -This is correct functionality, but the lifetime contract is not explicit enough for host frameworks. Sharing such an instance at the wrong scope can leak cookie, breaker, limiter or test state between unrelated requests/jobs. +Host lifetime rules must therefore be explicit and tested. ### 3.3 Webhook replay abstraction is correctly host-neutral -WebhookReplayStore::claim(namespace, deliveryId, ttlSeconds) already expresses the right lower-layer requirement: +WebhookReplayStore::claim(namespace, deliveryId, ttlSeconds) expresses the correct lower-layer requirement: - atomic first claim; - bounded TTL; - duplicate detection. -Foundation can implement the contract with CacheLayer without creating a TalkingBytes-to-CacheLayer dependency. - -The contract should be hardened and tested, not replaced. +Foundation should keep its CacheLayer implementation. TalkingBytes should harden the contract and tests, not acquire a CacheLayer dependency. ### 3.4 Inbound gRPC currently stops at dispatch -GrpcInboundDispatcher correctly maps normalized inbound requests to application handlers. +GrpcInboundDispatcher maps normalized inbound requests to handlers but does not provide a host-facing accepted-call/source/exchange boundary that a Foundation worker can drive one call at a time. + +### 3.5 Inbound gRPC currently leaks implementation detail into response metadata + +When an inbound handler throws, GrpcInboundDispatcher returns a GrpcInboundResponse containing the exception class in response metadata. + +That metadata can cross the protocol boundary. Internal exception classes must not be returned to remote callers by default. + +The exception class may be retained in local observability where policy permits, but not in the wire response. + +### 3.6 Observability redaction is not uniformly strict + +Some protocol events still include raw result errors or exception messages. + +Email/spool events can also expose operational paths or message subjects. These are not authentication secrets, but they may be sensitive application/PII data and should not be default observability fields when a stable identifier/category is sufficient. + +### 3.7 Optional capability coldness needs release evidence + +Native gRPC and multiple mail-related capabilities are optional by design. + +Unrelated protocol use must not eagerly require or initialize optional capabilities. + +### 3.8 Timing is only partially monotonic + +Core/Support/Clock already supports a monotonic source using hrtime, and gRPC retry uses it. + +Other runtime paths still use microtime(true) or time() for durations/deadlines, including: + +- HTTP transport/pool durations; +- webhook delivery duration; +- gRPC client/transport/inbound event durations; +- SMTP command timing; +- sendmail timeout handling; +- IMAP/POP3 deadlines; +- mailbox watch loops; +- spool receive events; +- Emailer events. + +Elapsed time and deadlines should use the monotonic clock. Wall time should remain only where protocol semantics require real timestamps, such as webhook signature timestamps. + +### 3.9 Waiting and cancellation are inconsistent + +TalkingBytes already has Sleeper and mailbox watch callbacks such as shouldStop, but waiting behavior is fragmented: + +- retry paths sleep through Sleeper; +- sendmail uses raw usleep loops; +- POP3 watch uses time plus raw usleep; +- IMAP watch uses stream_select plus raw usleep fallback; +- generated/native gRPC stream loops do not expose a uniform cancellation check; +- HTTP multi does not accept a cooperative cancellation signal. + +Persistent hosts need one small lower-layer cancellation contract so Foundation can adapt its heartbeat/stop/release policy without TalkingBytes depending on Foundation. + +### 3.10 Sendmail process supervision is weaker than Foundation process execution + +SendmailTransport owns a private proc_open loop and terminates the direct child with proc_terminate. + +Foundation has stronger generic process handling with timeout/cancellation and optional POSIX process-group termination. The full Foundation ProcessRunner must not move into TalkingBytes because Foundation uses it for console/scheduler/application process execution. + +TalkingBytes should instead own a narrow sendmail child-process supervisor with: + +- array command/no shell; +- bounded stdout/stderr capture; +- monotonic timeout; +- cooperative cancellation; +- graceful then forced termination; +- optional POSIX process-group termination when safely available; +- portable direct-child fallback. + +### 3.11 Foundation duplicates TalkingBytes protocol composition + +Foundation CommunicationProfiles currently composes TalkingBytes behavior for: + +- HTTP auth; +- CookieJar; +- HTTP retry; +- RateLimiter; +- CircuitBreaker; +- idempotency; +- gRPC retry; +- generated/native gRPC client selection; +- webhook signing and retry. + +Foundation EmailProfiles currently composes: -TalkingBytes does not yet expose a clean host-facing accepted-call/runtime boundary that a Foundation worker can drive one cycle at a time. +- transport-driver selection; +- fallbacks; +- retry; +- rate limiting; +- DKIM; +- sender transport config. -Foundation should not recreate gRPC protocol adaptation merely to fit its worker lifecycle. +Foundation NotificationGraphFactory also reconstructs EmailLimits from arrays. -### 3.5 Observability redaction is not uniformly strict +These are protocol-native composition concerns once paths/secrets/default profile names have already been resolved. -HTTP, webhook and mailbox paths already contain useful redaction, but some gRPC and email event paths can expose raw failure strings. +### 3.12 Generated gRPC stub adaptation uses exception-driven signature probing -Raw exception messages may contain endpoints, metadata or credential-bearing lower-layer text. Runtime events should prefer structured and sanitized failure data. +GeneratedStubGrpcInvoker opens streaming calls by attempting one invocation signature and catching ArgumentCountError or TypeError before trying another. -### 3.6 Optional capability coldness needs release evidence +Catching TypeError around the method invocation can accidentally treat a real TypeError from inside a user/generated stub as an invocation-shape mismatch and may duplicate side effects. -Native gRPC and several mail-related extensions/packages are optional by design. +Resolve/validate the call shape deterministically instead of probing by executing and catching broad TypeError. -The release must explicitly prove that unrelated protocol usage does not eagerly require or initialize optional capabilities. +### 3.13 HTTP multi concurrency is chunked, not a rolling window + +CurlMultiTransport currently array-chunks requests by max concurrency, waits for the full chunk to complete, then schedules the next chunk. + +This causes avoidable head-of-line blocking when one slow request holds back scheduling even though another slot has become free. + +A rolling-window scheduler can improve throughput and latency without adding threads or fork-based concurrency. + +### 3.14 Raw global error-handler usage requires an isolation audit + +Several paths temporarily call set_error_handler for warning capture/suppression. + +Most restore it in finally and do not deliberately suspend a Fiber while installed, so this is not automatically a defect. Still, the release should prove that no code path can yield/call arbitrary user code while a temporary process-global handler is installed. + +Prefer expression-local/native error handling where practical. --- -## 4. Workstream 1 — P0 Runtime Event-State Isolation +## 4. Workstream 1 — P0 Runtime Global-State Isolation ### Goal -Injected EventDispatcher instances become the authoritative runtime observability mechanism. - -CommunicationEventBus remains compatibility-only. +Normal TalkingBytes object graphs must not depend on process-global mutable state. ### Tasks -- [ ] Add or propagate optional EventDispatcher dependencies through native email factories where required. -- [ ] Convert SpoolEmailReceiver lifecycle events from static bus dispatch to an injected dispatcher. +- [ ] Propagate optional EventDispatcher dependencies through email sender/receiver/mailbox/parser factories where events are emitted. +- [ ] Convert SpoolEmailReceiver lifecycle events to injected dispatch. - [ ] Convert mailbox command events away from direct CommunicationEventBus use. - [ ] Convert BounceParser event emission away from direct CommunicationEventBus use. -- [ ] Audit every production src/ reference to CommunicationEventBus. -- [ ] Ensure newly created protocol/runtime code never requires process-global event state. -- [ ] Preserve CommunicationEventBus only as a compatibility facade. -- [ ] Keep dispatch best-effort: observer failures must never alter protocol results. -- [ ] Add sequential persistent-runtime tests proving event listeners do not leak between operations. -- [ ] Add Fiber-interleaving tests for execution paths that can overlap. -- [ ] Update events documentation to make injection the primary path. +- [ ] Audit every production src reference to CommunicationEventBus. +- [ ] Keep CommunicationEventBus only as a compatibility facade. +- [ ] Ensure new runtime code never requires the static bus. +- [ ] Keep dispatch best-effort: listener failures must not alter protocol results or cleanup. +- [ ] Audit temporary set_error_handler regions. +- [ ] Ensure no temporary global error handler spans arbitrary user callbacks, Fiber suspension, event dispatch or long-lived loops. +- [ ] Add sequential persistent-runtime tests proving event listeners and temporary runtime state do not leak. +- [ ] Add Fiber-interleaving tests for relevant stateless/object-scoped paths. +- [ ] Update events documentation to make injection authoritative. ### Acceptance -A normal object graph created through public factory/constructor APIs must not depend on static event state. +A normal graph created through public constructors/factories must work correctly with CommunicationEventBus untouched. --- -## 5. Workstream 2 — P0 Mutable-State Lifetime Contracts +## 5. Workstream 2 — P0 Monotonic Time, Cancellation and Interruptible Waiting ### Goal -Document and test which objects are immutable, reusable or execution/session state. +Long-running/retrying operations become host-controllable without TalkingBytes owning the host lifecycle. + +### Direction + +Introduce the smallest useful cancellation abstraction. Exact naming may change. + +Conceptually: + +- CancellationSignal::isRequested(): bool; +- a never-cancelled implementation; +- optional adapter from a callable; +- no dependency on Foundation; +- no global registry. + +Do not create a general task framework. + +### Tasks + +- [ ] Standardize elapsed durations and internal deadlines on Core/Support/Clock::monotonic(). +- [ ] Keep Clock::timestamp()/wall time only for protocol timestamps that require real time. +- [ ] Extend waiting support so retry/backoff sleeps can be interrupted in bounded slices when a cancellation signal is supplied. +- [ ] Keep the current simple Sleeper path cheap when no cancellation is supplied. +- [ ] Allow RetryExecutor to stop before the next attempt when cancelled. +- [ ] Allow HTTP retry and gRPC retry to stop before sleeping/retrying when cancelled. +- [ ] Allow WebhookSender retry to stop cooperatively. +- [ ] Allow mailbox watch loops to consume the same cancellation abstraction while retaining callable compatibility where practical. +- [ ] Allow generated/native gRPC streaming loops to check cancellation between messages/writes/reads where the native API permits. +- [ ] Allow the inbound gRPC accepted-call bridge to stop before accepting the next exchange. +- [ ] Allow CurlMultiTransport to stop scheduling and terminate/close active work safely when host cancellation is requested, if libcurl semantics permit deterministic cleanup. +- [ ] Add deterministic fake-clock/fake-sleeper/cancellation tests. +- [ ] Verify cancellation never skips required resource cleanup. + +### Foundation handoff + +Foundation adapts heartbeat loss, stop token and release-generation replacement into the TalkingBytes cancellation boundary. TalkingBytes does not know those Foundation concepts. + +--- + +## 6. Workstream 3 — P0 Mutable-State Lifetime Contracts ### Required classifications @@ -204,141 +379,266 @@ Document and test which objects are immutable, reusable or execution/session sta | GrpcClient | immutable graph over invoker | invoker lifetime dependent | | GrpcInboundDispatcher | immutable handler graph | reusable if handlers are safe | | Emailer | immutable graph over transport | transport lifetime dependent | +| SMTP transport | per-send connection today | reusable graph if collaborators are safe | | mailbox/socket transports | connection/session state | execution/worker owned | +| generated gRPC/native invokers | native channel/stub lifetime dependent | host/profile scoped deliberately | | fakes/spies | mutable test state | test scoped | ### Tasks -- [ ] Add the lifetime matrix to architecture/runtime documentation. -- [ ] Prove fluent client operations never mutate previous instances. -- [ ] Prove cookie state exists only when a CookieJar is explicitly attached. -- [ ] Prove separate cookie jars do not cross-contaminate. -- [ ] Prove separate CircuitBreaker instances do not share counters. -- [ ] Prove separate RateLimiter instances do not share tokens. -- [ ] Add Fiber/interleaving tests around mutable collaborators. -- [ ] Keep shared resilience state an explicit host decision. -- [ ] Do not introduce a global resilience registry. -- [ ] Verify auth tokens and credentials are retained only inside explicitly configured client graphs. +- [ ] Publish the matrix in architecture/runtime docs. +- [ ] Prove fluent operations do not mutate previous instances. +- [ ] Prove CookieJar isolation. +- [ ] Prove CircuitBreaker isolation. +- [ ] Prove RateLimiter isolation. +- [ ] Prove mailbox connections are not shared accidentally across scoped graphs. +- [ ] Document native gRPC stub/channel lifetime expectations. +- [ ] Add sequential/Fiber tests around mutable collaborators. +- [ ] Do not introduce global resilience or native-client registries. - [ ] Ensure fake/spy state has deterministic new-instance/reset behavior. --- -## 6. Workstream 3 — P0 Webhook Replay Hardening +## 7. Workstream 4 — P0 Webhook Replay Hardening ### Goal -Keep replay protection protocol-owned but storage-provider-neutral. +Replay protection remains protocol-owned and storage-provider-neutral. ### Tasks - [ ] Keep WebhookReplayStore minimal. -- [ ] Document that claim must be atomic across competing processes for production use. -- [ ] Document that replay backend errors must fail closed. -- [ ] Add a contention contract test where only one contender wins the same delivery claim. -- [ ] Add a throwing-store test proving WebhookReceiver does not silently bypass replay protection. +- [ ] Document that production claim must be atomic across competing processes. +- [ ] Document backend errors as fail-closed. +- [ ] Add a contention contract test where only one contender wins. +- [ ] Add a throwing-store test proving replay protection is not bypassed. - [ ] Preserve strict positive TTL validation. -- [ ] Preserve bounded namespace and delivery-ID validation. +- [ ] Preserve bounded namespace/delivery-ID validation. - [ ] Preserve signature/timestamp verification before replay claim. -- [ ] Preserve replay claim before a verified event is returned to application code. -- [ ] Ensure replay observability never exposes raw secret, signature or body data. -- [ ] Do not add CacheLayer as a TalkingBytes dependency. +- [ ] Preserve replay claim before a verified event is returned. +- [ ] Mark InMemoryWebhookReplayStore clearly as single-process/test/local-use unless its guarantees are sufficient for the documented deployment. +- [ ] Ensure replay observability never exposes raw secret/signature/body. +- [ ] Do not add CacheLayer. ### Foundation handoff -Foundation continues implementing WebhookReplayStore using CacheLayer atomic setIfAbsent under Foundation's own security cache-key domain. +Foundation keeps CacheLayerWebhookReplayStore and its Foundation security cache-key domain. --- -## 7. Workstream 4 — P0 Host-Controlled Inbound gRPC Runtime Bridge +## 8. Workstream 5 — P0 Host-Controlled Inbound gRPC Runtime Bridge ### Goal -Allow Foundation or another host to execute inbound gRPC through its own worker lifecycle without reimplementing TalkingBytes protocol adaptation. - -GrpcInboundDispatcher remains the application dispatch boundary. - -TalkingBytes does not become the worker supervisor. +Foundation or another host can run inbound gRPC through its own lifecycle without recreating TalkingBytes protocol adaptation. ### Target flow native/server runtime -→ TalkingBytes inbound adapter/source -→ accepted gRPC exchange -→ normalized GrpcInboundRequest +→ TalkingBytes inbound source/adapter +→ accepted exchange +→ GrpcInboundRequest → GrpcInboundDispatcher → GrpcInboundResponse -→ native exchange completion - -Exact class names may change while implementing. The ownership boundary must not. +→ TalkingBytes exchange completion ### Required characteristics -- [ ] Add a small host-facing contract for obtaining/accepting one inbound gRPC exchange. -- [ ] The accepted exchange exposes a normalized GrpcInboundRequest. -- [ ] TalkingBytes owns mapping GrpcInboundResponse/status/metadata back to the native exchange. -- [ ] Provide a single-cycle or otherwise host-controllable execution API. -- [ ] Allow Foundation to check heartbeat, stop token and release generation between accepted calls. -- [ ] Do not hide an infinite process loop inside TalkingBytes unless cancellation/lifecycle control is explicit. -- [ ] Preserve gRPC method normalization. -- [ ] Preserve metadata mapping. -- [ ] Preserve deadline mapping. -- [ ] Preserve status/error mapping. -- [ ] Add a fake inbound source/exchange for deterministic integration tests. +- [ ] Add a small contract for accepting/obtaining one inbound gRPC exchange. +- [ ] Accepted exchange exposes normalized GrpcInboundRequest. +- [ ] TalkingBytes maps GrpcInboundResponse/status/metadata back to the native exchange. +- [ ] Provide a one-cycle or otherwise host-controllable execution API. +- [ ] Accept cancellation between calls and, where supported, during streams. +- [ ] Do not hide an uncontrolled infinite process loop. +- [ ] Preserve method normalization, metadata, deadline and status mapping. +- [ ] Add fake inbound source/exchange utilities. - [ ] Do not add socket/process supervision. - [ ] Do not require Foundation or Omnibus. -- [ ] Keep ext-grpc and generated-runtime dependencies optional until their adapters are selected. -- [ ] Document the exact inbound streaming modes actually supported. -- [ ] Do not claim inbound server/client/bidirectional streaming unless native inbound adapters implement them. -- [ ] If inbound streaming is added, keep it incremental, bounded and host-cancellable. +- [ ] Keep ext-grpc and grpc/grpc cold until selected. +- [ ] Document exact inbound streaming modes actually implemented. +- [ ] Keep inbound streaming incremental and bounded. -### Foundation handoff +### Security correction + +- [ ] Remove handler exception class from GrpcInboundResponse wire metadata. +- [ ] Return stable INTERNAL status/message only. +- [ ] Keep richer exception classification only in local events/logging when safe. +- [ ] Add a test proving remote responses do not reveal exception class, file path, trace or raw exception message. + +--- + +## 9. Workstream 6 — P0/P1 Persistent-Runtime Email and Sendmail Process Hardening + +### Email runtime tasks + +- [ ] Propagate injected EventDispatcher objects through EmailSenderFactory, EmailReceiverFactory and EmailMailboxFactory. +- [ ] Keep Emailer transport composition native to TalkingBytes. +- [ ] Keep SMTP/sendmail/mail/spool behavior native. +- [ ] Keep IMAP/POP3 behavior native. +- [ ] Keep MIME/parsing/DKIM/bounce behavior native. +- [ ] Define mailbox connection ownership and deterministic close/logout behavior. +- [ ] Ensure failed sessions cannot poison newly constructed instances. +- [ ] Preserve bounded line/message/attachment/parser limits. +- [ ] Preserve spool locking, quarantine and safe move semantics. +- [ ] Replace wall-clock logical deadlines with monotonic clock. +- [ ] Replace raw watch-loop sleeps with injectable waiting where useful. +- [ ] Keep IMAP IDLE cancellation responsive. +- [ ] Keep POP3 polling cancellation responsive. +- [ ] Add persistent-worker and cancellation tests. + +### Sendmail subprocess tasks + +- [ ] Keep command execution as an argument array and bypass the shell. +- [ ] Extract the private process loop into a narrow internal sendmail child-process helper if that reduces duplication/complexity. +- [ ] Use monotonic timeout. +- [ ] Add cooperative cancellation. +- [ ] Keep stdout/stderr capture bounded. +- [ ] Terminate gracefully, wait a bounded grace period, then force termination. +- [ ] When posix_setpgid/posix_getpgid/posix_kill are available and safe, place the child in its own process group and terminate the group so descendants are not orphaned. +- [ ] Fall back to direct proc_terminate when POSIX group control is unavailable. +- [ ] Do not require ext-posix. +- [ ] Do not require ext-pcntl. +- [ ] Do not import Foundation ProcessRunner or make TalkingBytes a generic process package. +- [ ] Add tests for timeout, cancellation, forced termination and cleanup. +- [ ] Add optional Unix process-group coverage where CI supports it. +- [ ] Verify Windows/non-POSIX fallback behavior remains valid. + +### pcntl policy + +- [ ] Do not register SIGINT/SIGTERM handlers inside SendmailTransport, SMTP, HTTP, webhook or gRPC normal paths. +- [ ] Foundation continues translating its worker signals into cancellation. +- [ ] Consider an explicit standalone PcntlSignalCancellation adapter only if a non-Foundation CLI use case justifies it. +- [ ] If such an adapter is added, it must restore previous handlers and never become a default dependency path. + +--- + +## 10. Workstream 7 — P1 Native Composition Builders to Shrink Foundation -Foundation wraps this one-cycle protocol boundary with the existing Foundation worker heartbeat, stop-token and release-generation lifecycle. +### Goal + +Foundation should select named profiles and resolve application values. TalkingBytes should turn resolved protocol configuration into protocol objects. + +Do not introduce Foundation-specific configuration names or a large profile framework. + +Prefer extending existing factories/facades before adding many new abstractions. + +### HTTP composition + +Move the mechanics currently in Foundation CommunicationProfiles::decorateHttp into a TalkingBytes-native builder/factory: + +- [ ] auth driver composition; +- [ ] CookieJar opt-in; +- [ ] retry policy composition; +- [ ] RateLimiter composition; +- [ ] CircuitBreaker composition; +- [ ] idempotency middleware composition. + +Foundation should still: + +- choose the named HTTP profile; +- resolve secrets; +- enforce production TLS policy; +- decide DI lifetime. + +### gRPC composition + +- [ ] Add a direct TalkingBytes convenience path for generated stubs so Foundation does not construct GeneratedStubGrpcInvoker itself unless it needs customization. +- [ ] Centralize native/generated/streaming client composition in TalkingBytes. +- [ ] Centralize gRPC retry-profile application in TalkingBytes. +- [ ] Allow EventDispatcher injection through usingNative/usingNativeStreaming/generated-stub paths. +- [ ] Keep service/handler lookup in Foundation. + +### Webhook composition + +- [ ] Keep signing, verifier/receiver creation and retry-profile mechanics in TalkingBytes. +- [ ] Allow a resolved outbound/inbound config array or small typed config to be applied without Foundation recreating protocol rules. +- [ ] Keep secret source resolution and production-secret policy in Foundation. +- [ ] Keep replay-store implementation in Foundation. + +### Email composition + +Expand native email factory capability so Foundation no longer has to own protocol transport/decorator mechanics: + +- [ ] transport driver creation from resolved transport config; +- [ ] fallback transport composition; +- [ ] retry policy composition; +- [ ] rate-limit composition; +- [ ] DKIM config/application after path/secret resolution; +- [ ] parser-limit parsing. + +Specific easy win: + +- [ ] add EmailLimits::fromArray() using TalkingBytes-native strict config parsing so Foundation NotificationGraphFactory does not duplicate EmailLimits construction. + +Foundation should still: + +- choose named sender/transport/mailbox/receiver profiles; +- resolve relative application paths; +- resolve private keys/secrets from application configuration; +- apply default From policy; +- own notification/template routing. + +### Acceptance + +After the Foundation follow-up: + +- CommunicationProfiles should mostly perform profile lookup, host policy and delegation. +- EmailProfiles should mostly perform profile lookup/path resolution and delegation. +- no protocol retry/auth/cookie/DKIM/fallback algorithm should be recreated in Foundation. --- -## 8. Workstream 5 — P1 Persistent-Runtime Email Hardening +## 11. Workstream 8 — P1 HTTP Concurrent Scheduler and Runtime Control ### Goal -Keep TalkingBytes' full native inbound/outbound email system while making persistent-worker ownership explicit. +Improve throughput without threads, forks or a new async framework. ### Tasks -- [ ] Propagate injected EventDispatcher objects through EmailSenderFactory, EmailReceiverFactory and EmailMailboxFactory where required. -- [ ] Keep Emailer transport composition native to TalkingBytes. -- [ ] Keep SMTP/sendmail/mail/spool behavior native. -- [ ] Keep IMAP/POP3 mailbox behavior native. -- [ ] Keep MIME/parsing/DKIM/bounce behavior native. -- [ ] Define mailbox/socket connection ownership. -- [ ] Define logout/close/shutdown behavior for connection-bearing objects. -- [ ] Ensure one failed mailbox/session cannot poison subsequently constructed instances. -- [ ] Preserve bounded line/message/attachment/parse limits. -- [ ] Preserve spool processing/quarantine behavior. -- [ ] Preserve file locking and safe move semantics. -- [ ] Add sequential persistent-worker tests for sender, receiver and mailbox boundaries. -- [ ] Add Fiber/interleaving tests for stateless parser paths where useful. -- [ ] Prove no static event listener leaks across mail operations. +- [ ] Replace array_chunk batch scheduling with a rolling cURL multi window up to maxConcurrency. +- [ ] As soon as one handle completes, schedule the next pending request. +- [ ] Preserve result ordering by original keys. +- [ ] Preserve bounded concurrency. +- [ ] Preserve cleanup on every failure/listener/cancellation path. +- [ ] Preserve current truthful stopSchedulingOnFailure semantics. +- [ ] When a failure is observed and stop-scheduling is enabled, stop adding new requests immediately. +- [ ] Do not claim active-request fail-fast cancellation unless it is actually implemented. +- [ ] If cancellation is supplied, close/remove active handles safely and return deterministic cancelled results/metadata. +- [ ] Keep manual redirect security behavior; do not re-enable unsafe automatic redirect handling in CurlMultiTransport. +- [ ] Move pool durations to monotonic Clock. +- [ ] Benchmark chunked 2.0 behavior versus rolling-window 2.1 behavior with mixed fast/slow fake/local endpoints. +- [ ] Track allocation/handle cleanup under repeated runs. ### Non-goal -Do not add Foundation-specific profile/config objects to TalkingBytes. +Do not add pcntl_fork, pthreads, parallel, ReactPHP or Amp merely for this scheduler. --- -## 9. Workstream 6 — P1 Observability and Secret Redaction +## 12. Workstream 9 — P1 gRPC Native Adapter Determinism and Streaming Control -### Goal +### Tasks -No default protocol event or log context should expose application secrets. +- [ ] Remove exception-driven TypeError probing for generated streaming call shape. +- [ ] Resolve the supported generated-stub call shape before executing the real call. +- [ ] Prefer explicit adapter metadata/callable strategy or bounded reflection cached at adapter construction. +- [ ] Never retry an invocation merely because a TypeError was thrown from inside the invoked method. +- [ ] Validate method maps early. +- [ ] Keep generated/native package capability checks cold. +- [ ] Add cancellation checks between outbound stream writes and inbound reads where possible. +- [ ] Preserve incremental streaming; never accumulate full streams. +- [ ] Ensure callback exceptions close/finalize native call resources deterministically. +- [ ] Add tests proving no duplicate side effect occurs during call-shape resolution. +- [ ] Add tests for cancellation, callback failure and final status/trailer handling. -### Audit domains +--- -- HTTP -- Webhook -- gRPC -- Email -- Mailbox +## 13. Workstream 10 — P1 Observability, Redaction and Data-Minimization + +### Goal + +Default events/log context must not expose secrets or unnecessary payload/PII. ### Tasks @@ -346,156 +646,182 @@ No default protocol event or log context should expose application secrets. - [ ] Never emit raw bearer/API tokens. - [ ] Never emit cookie values. - [ ] Never emit proxy credentials. -- [ ] Never emit webhook secrets. -- [ ] Never emit raw webhook signatures. -- [ ] Never emit webhook bodies as observability fields. -- [ ] Never emit SMTP/mailbox passwords. -- [ ] Never emit raw authentication commands. +- [ ] Never emit webhook secrets/signatures/bodies. +- [ ] Never emit SMTP/mailbox passwords or raw auth commands. - [ ] Avoid raw gRPC metadata values unless explicitly classified safe. -- [ ] Stop blindly copying raw exception messages into protocol events. -- [ ] Prefer exception class, status/code and bounded sanitized categories. -- [ ] Keep caller-facing CommunicationResult detail useful; observability may deliberately be stricter. -- [ ] Add sentinel-secret tests and assert sentinel values never appear in emitted event/log payloads. -- [ ] Keep redaction overhead bounded on hot paths. +- [ ] Do not copy raw exception messages blindly into protocol events. +- [ ] Prefer stable failure category, exception class where locally appropriate, protocol status/code and bounded sanitized diagnostics. +- [ ] Remove exception class from remote gRPC response metadata. +- [ ] Review SMTP transcript capture and document it as explicit diagnostic data with clear redaction guarantees. +- [ ] Remove or gate spool absolute paths and email subjects from default events when they are not required. +- [ ] Keep caller-facing CommunicationResult diagnostics useful; local observability may intentionally be stricter. +- [ ] Add sentinel-secret and sentinel-PII tests across HTTP, webhook, gRPC, email and mailbox event payloads. +- [ ] Keep hot-path redaction overhead bounded. --- -## 10. Workstream 7 — P1 Optional Capability Coldness - -### Goal - -Selecting one protocol must not eagerly activate another protocol's optional requirements. +## 14. Workstream 11 — P1 Optional Capability and Extension Coldness ### Acceptance matrix -- [ ] HTTP works without ext-grpc and grpc/grpc. +- [ ] HTTP works without ext-grpc, grpc/grpc, ext-posix and ext-pcntl. - [ ] Webhook works without native gRPC packages. -- [ ] Basic outbound email does not require IMAP-specific extensions. -- [ ] IMAP/POP3 optional capability checks occur only when those paths are selected. +- [ ] Basic outbound email works without IMAP-specific optional extensions. +- [ ] SMTP works without ext-posix/ext-pcntl. +- [ ] Sendmail works with portable proc_* fallback when ext-posix is absent. +- [ ] POSIX process-group hardening activates only when functions are available. +- [ ] IMAP/POP3 optional checks occur only when selected. - [ ] RSA DKIM does not require Sodium. - [ ] Ed25519 DKIM fails clearly only when selected and Sodium is unavailable. -- [ ] Native/generated gRPC paths fail with actionable messages only when selected. -- [ ] Composer suggest metadata matches real runtime requirements. -- [ ] Documentation matches Composer optional capability metadata. +- [ ] Native/generated gRPC fails clearly only when selected. +- [ ] Composer suggest metadata matches actual optional behavior. +- [ ] Add ext-posix to suggest only if the released implementation actually uses it as an optional sendmail hardening path. +- [ ] Do not add ext-pcntl to suggest unless an explicit public pcntl adapter is shipped. +- [ ] Documentation matches Composer metadata. - [ ] Avoid unrelated extension/class probing on protocol hot paths. --- -## 11. Workstream 8 — P1 Native Benchmark Evidence +## 15. Workstream 12 — P1 Native Benchmark and Soak Evidence TalkingBytes owns native protocol benchmarks. -Foundation owns Foundation-bridge comparison benchmarks. +Foundation owns bridge attribution. -### HTTP benchmark coverage +### HTTP - [ ] immutable client construction; +- [ ] resolved-profile/factory construction; - [ ] request preparation; - [ ] fake transport send; - [ ] cookie-enabled send; -- [ ] retry middleware overhead; -- [ ] rate-limiter overhead; -- [ ] circuit-breaker overhead; -- [ ] repeated-run memory growth. +- [ ] retry/rate-limit/circuit overhead; +- [ ] rolling multi scheduler; +- [ ] cancellation cleanup; +- [ ] repeated-run memory/handle growth. -### Webhook benchmark coverage +### Webhook - [ ] signing; - [ ] verification; - [ ] verification plus replay claim; -- [ ] duplicate rejection. +- [ ] duplicate rejection; +- [ ] retry/cancellation overhead. -### gRPC benchmark coverage +### gRPC -- [ ] unary client dispatch; +- [ ] unary dispatch; - [ ] inbound dispatcher; -- [ ] new host inbound-exchange adapter; -- [ ] retry decision path; -- [ ] generated/native adapter overhead when available; -- [ ] streaming adapter overhead without eager stream materialization. +- [ ] host accepted-exchange bridge; +- [ ] retry decision; +- [ ] generated/native adapter; +- [ ] streaming without eager materialization; +- [ ] cancellation check overhead. -### Email benchmark coverage +### Email - [ ] message preparation; - [ ] null/fake send; - [ ] parser; - [ ] spool receive; -- [ ] deterministic mailbox adapter overhead where practical. +- [ ] deterministic mailbox adapter; +- [ ] sendmail process-control overhead; +- [ ] 1/10/25 MB payload paths already relevant to the existing benchmark suite. -### Benchmark rules +### Rules - [ ] Do not add Foundation as a benchmark dependency. -- [ ] Separate CPU microbenchmarks from network/disk I/O. +- [ ] Separate CPU microbenchmarks from network/disk/process I/O. - [ ] Record peak memory where meaningful. -- [ ] Add repeated-run checks for unexpected memory/state growth. -- [ ] Preserve clear ownership attribution. +- [ ] Add repeated-run soak checks for state/resource growth. +- [ ] Use monotonic timing for benchmark duration measurement. +- [ ] Preserve clear attribution. --- -## 12. Workstream 9 — Documentation and Release Metadata +## 16. Documentation and Release Metadata -- [ ] Update architecture docs with the lifetime/ownership matrix. -- [ ] Update events docs: injected dispatcher primary, static bus compatibility-only. -- [ ] Update webhook replay docs with atomic and fail-closed requirements. -- [ ] Update gRPC inbound docs for the host-runtime bridge. +- [ ] Update architecture docs with ownership/lifetime/cancellation boundaries. +- [ ] Update events docs: injected dispatcher primary; static bus compatibility-only. +- [ ] Update HTTP concurrency docs for rolling scheduling and cancellation semantics. +- [ ] Update webhook replay docs with atomic/fail-closed requirements. +- [ ] Update gRPC inbound docs for the host-runtime bridge and wire-error data minimization. +- [ ] Update gRPC generated/native docs for deterministic adapter behavior. - [ ] Update email docs for persistent-worker connection ownership. -- [ ] Update security docs with secret/redaction guarantees. +- [ ] Update sendmail docs for timeout/cancellation/POSIX optional behavior. +- [ ] Update security docs with secret/PII redaction guarantees. - [ ] Update performance docs with persistent-runtime guidance. -- [ ] Update testing docs with isolation and fake inbound-runtime examples. -- [ ] Update release checklist with static-state, optional-cold and secret-sentinel gates. -- [ ] Keep README examples aligned with the released API. -- [ ] Keep Composer requirements/suggestions synchronized with actual runtime behavior. +- [ ] Update testing docs with isolation, fake cancellation and fake inbound-runtime examples. +- [ ] Update release checklist with static-state, monotonic-time, cancellation, optional-cold and secret-sentinel gates. +- [ ] Keep README examples aligned with released APIs. +- [ ] Keep Composer requirements/suggestions synchronized with real runtime behavior. --- -## 13. Likely File Touch Map +## 17. Likely File Touch Map -This is a planning map, not a requirement to modify every listed file. +This is a planning map, not a requirement to modify every file. -### Core/events +### Core/runtime support -- src/Core/Event/CommunicationEventBus.php -- src/Core/Event/EventDispatcher.php -- src/Core/Event/BestEffortEventDispatcher.php -- docs/events.rst +- src/Core/Event/* +- src/Core/Support/Clock.php +- src/Core/Support/Sleeper.php +- src/Core/Support/RetryExecutor.php +- new minimal cancellation support if required + +### HTTP + +- src/Http/HttpClient.php +- src/Http/HttpClientConfig.php +- optional new native HttpClientFactory or equivalent +- src/Http/Concurrent/CurlMultiTransport.php +- src/Http/Concurrent/RequestPool.php +- src/Http/Transport/CurlTransport.php +- src/Http/Middleware/* +- src/Http/Cookie/CookieJar.php +- src/Resilience/CircuitBreaker.php +- src/Resilience/RateLimiter.php ### Email - src/Email/Email.php +- src/Email/Emailer.php - src/Email/EmailSenderFactory.php - src/Email/EmailReceiverFactory.php - src/Email/EmailMailboxFactory.php +- src/Email/Config/EmailLimits.php +- src/Email/Config/DkimConfig.php where useful +- src/Email/Transport/SendmailTransport.php +- src/Email/Transport/SmtpTransport.php - src/Email/Receiver/SpoolEmailReceiver.php - src/Email/Mailbox/SocketMailboxRuntime.php +- src/Email/Mailbox/ImapSocketTransport.php +- src/Email/Mailbox/Pop3SocketTransport.php - src/Email/Parser/BounceParser.php -- relevant Email/Mailbox/Bounce tests - -### HTTP/state - -- src/Http/HttpClient.php -- src/Http/Cookie/CookieJar.php -- src/Http/Middleware/* -- src/Resilience/CircuitBreaker.php -- src/Resilience/RateLimiter.php -- relevant HTTP/resilience tests +- relevant tests ### Webhook -- src/Webhook/Contracts/WebhookReplayStore.php +- src/Webhook/Webhook.php +- src/Webhook/WebhookSender.php - src/Webhook/WebhookReceiver.php - src/Webhook/WebhookVerifier.php -- src/Webhook/WebhookSender.php -- tests/Webhook* -- docs/webhook/* +- src/Webhook/Contracts/WebhookReplayStore.php +- src/Webhook/Replay/InMemoryWebhookReplayStore.php +- relevant tests/docs ### gRPC +- src/Grpc/GrpcClient.php - src/Grpc/GrpcInboundDispatcher.php -- src/Grpc/Receiver/* +- src/Grpc/Middleware/RetryMiddleware.php +- src/Grpc/Native/GeneratedStubGrpcInvoker.php - src/Grpc/Native/* +- src/Grpc/Receiver/* - src/Grpc/Testing/* -- tests/Grpc* -- docs/grpc/* +- optional native GrpcClientFactory or equivalent +- relevant tests/docs ### Benchmarks/release @@ -511,124 +837,208 @@ This is a planning map, not a requirement to modify every listed file. --- -## 14. Execution Order +## 18. Major/Minor Decision Gate + +### Stay on 2.1 when + +- new cancellation/time/factory APIs are additive; +- CommunicationEventBus can remain as a compatibility facade; +- existing constructor signatures can gain only optional parameters or factory alternatives; +- generated gRPC correction can preserve current public contracts; +- rolling HTTP scheduling changes internal behavior without invalidating documented guarantees; +- SendmailTransport can be hardened internally; +- Foundation can migrate to new native builders without removing old TalkingBytes entry points. + +### Promote to the next major only when implementation proves one of these is required + +- removing CommunicationEventBus rather than merely bypassing it; +- making EventDispatcher mandatory in existing public constructors; +- replacing existing public config keys/semantics incompatibly; +- removing/renaming public methods rather than adding better alternatives; +- changing gRPC streaming contracts incompatibly; +- changing Emailer/transport public ownership semantics incompatibly. + +### Next-major cleanup candidates, not 2.1 release gates + +- remove the static CommunicationEventBus completely; +- collapse superseded compatibility factories/methods after a deprecation period; +- consider a persistent SMTP session/connection-reuse abstraction with strict idle/max-message/reset/fork-safety policy; +- reconsider any public cancellation/config APIs that cannot be made cleanly additive; +- remove legacy configuration aliases if they exist and are no longer valuable. + +--- + +## 19. Execution Order -### Batch 1 — Runtime-state cleanup +### Batch 1 — Runtime-state, clock and cancellation foundation -- remove static-event dependency from primary runtime paths; -- propagate EventDispatcher injection; -- add persistent-runtime and Fiber isolation coverage; -- document lifetime semantics. +- remove primary static-event dependency; +- add/propagate injected dispatch; +- standardize monotonic timing; +- introduce minimal cooperative cancellation; +- add persistent/Fiber isolation tests. -### Batch 2 — Secret and observability hardening +### Batch 2 — gRPC security and host boundary -- audit protocol events/logs; -- remove raw secret/failure leakage; -- add secret-sentinel tests. +- remove exception metadata leakage; +- add accepted-exchange/source boundary; +- add cancellation; +- add fake runtime; +- preserve status/deadline/metadata semantics. -### Batch 3 — Webhook replay acceptance +### Batch 3 — Email runtime and sendmail process hardening -- lock the atomic/fail-closed replay contract; -- add concurrency/error acceptance coverage; -- preserve storage-provider neutrality. +- event injection; +- mailbox/session ownership; +- monotonic deadlines; +- cancellation-aware watch behavior; +- sendmail child-process supervision; +- optional POSIX process-group safety. -### Batch 4 — Inbound gRPC hosting boundary +### Batch 4 — Native protocol composition builders -- introduce accepted-call/source contract; -- connect it to GrpcInboundDispatcher; -- add fake source/exchange; -- prove host-controlled one-cycle execution; -- prove deadline/status/metadata mapping. +- EmailLimits::fromArray; +- HTTP resolved-profile builder; +- email transport/decorator builder; +- gRPC native/generated/retry builder; +- webhook resolved-policy composition; +- tests proving Foundation no longer needs to recreate protocol mechanics. -### Batch 5 — Email persistent-runtime acceptance +### Batch 5 — Webhook replay acceptance -- complete event injection in mail paths; -- test connection/session ownership; -- retain native send/receive/parser behavior. +- atomic/fail-closed contract; +- contention/error tests; +- preserve provider neutrality. -### Batch 6 — Optional cold graphs +### Batch 6 — HTTP rolling multi scheduler -- test protocols with unrelated optional dependencies absent; -- align errors, docs and Composer metadata. +- rolling window; +- stop-scheduling behavior; +- cancellation/cleanup; +- throughput benchmark. -### Batch 7 — Native benchmarks and docs +### Batch 7 — gRPC generated adapter determinism -- expand native benchmark evidence; -- update architecture/security/testing/performance/release documentation. +- remove TypeError execution probing; +- deterministic call-shape resolution; +- streaming cancellation/failure cleanup. -### Batch 8 — Exact-head release gate +### Batch 8 — Observability and data minimization -Run the complete PHPForge matrix and tag only after the exact final head passes every closure gate. +- raw failure audit; +- secret/PII sentinels; +- transcript/path/subject policy. + +### Batch 9 — Optional cold graphs, docs and benchmarks + +- extension/package absence matrix; +- native benchmark evidence; +- architecture/security/testing/performance docs; +- Composer metadata. + +### Batch 10 — Exact-head release gate + +Run the full PHPForge and supported PHP/dependency matrix only after the final implementation head is frozen. --- -## 15. Foundation 3 Handoff +## 20. Foundation 3 Handoff After TalkingBytes 2.1 is released: -- [ ] Foundation raises its communication floor from ^2.0 to ^2.1 only if the new host-facing APIs are required. -- [ ] Foundation keeps CommunicationProfiles as application composition only. -- [ ] Foundation keeps HTTP clients scoped when cookie/resilience state can be mutable. -- [ ] Foundation does not duplicate TalkingBytes HTTP retry, signing, cookie or transport mechanics. -- [ ] Foundation keeps the CacheLayer replay implementation in Foundation. -- [ ] TalkingBytes keeps only the replay contract. -- [ ] Foundation selects webhook verifier/receiver lifetimes according to actual state. -- [ ] Foundation routes inbound gRPC through its existing worker heartbeat/stop/release lifecycle using the new TalkingBytes host-runtime boundary. -- [ ] Foundation does not implement the gRPC network stack. -- [ ] Foundation continues using native TalkingBytes sender/receiver/mailbox APIs. +- [ ] Foundation raises its communication floor to ^2.1 only when the released APIs are consumed. +- [ ] Foundation keeps named application profile lookup. +- [ ] Foundation keeps path/secret resolution and production policy. +- [ ] Foundation keeps DI lifetime selection. +- [ ] Foundation keeps CacheLayerWebhookReplayStore. +- [ ] Foundation keeps gRPC handler service lookup. +- [ ] Foundation keeps ProcessRunner for console/scheduler/application subprocesses. +- [ ] Foundation maps worker heartbeat/stop/release replacement into TalkingBytes cancellation. +- [ ] Foundation removes duplicated HTTP auth/cookie/retry/rate-limit/circuit/idempotency composition when TalkingBytes native composition is available. +- [ ] Foundation removes duplicated gRPC retry/native/generated composition when TalkingBytes owns it. +- [ ] Foundation removes duplicated webhook retry/signing composition where TalkingBytes can consume resolved values directly. +- [ ] Foundation removes duplicated email transport/fallback/retry/rate-limit/DKIM composition where TalkingBytes factories can consume resolved config. +- [ ] Foundation replaces manual EmailLimits construction with TalkingBytes parsing. +- [ ] Foundation keeps default From and notification/template routing as application policy. +- [ ] Foundation keeps HTTP clients scoped when mutable state is attached. +- [ ] Foundation routes inbound gRPC through the new host-controlled boundary. - [ ] Foundation proves communication secrets are absent from generated metadata, cache keys and logs. - [ ] Foundation adds direct-TalkingBytes-versus-Foundation bridge benchmark attribution. -- [ ] Foundation closes runtime-plan point 26.9 only on the exact-head PHPForge matrix. +- [ ] Foundation closes runtime plan point 26.9 only on exact-head green CI. + +### Expected Foundation simplification targets + +The follow-up should materially reduce logic in: + +- src/Communication/CommunicationProfiles.php; +- src/Communication/CommunicationGraphFactory.php; +- src/Notifications/EmailProfiles.php; +- src/Notifications/NotificationGraphFactory.php. + +Do not delete the host-policy parts of those classes merely to reduce line count. --- -## 16. Completion Gate +## 21. Completion Gate -TalkingBytes 2.1 is complete only when all of the following are true: +TalkingBytes 2.1 is complete only when: - [ ] no primary runtime path depends on process-global CommunicationEventBus state; -- [ ] mutable HTTP/resilience/session state has explicit lifetime semantics; -- [ ] sequential and Fiber-interleaved state-isolation tests pass; -- [ ] webhook replay is documented and tested as atomic and fail-closed; -- [ ] no CacheLayer or Foundation runtime dependency was introduced; -- [ ] inbound gRPC has a host-controllable adapter boundary suitable for Foundation workers; -- [ ] gRPC network/process ownership remains correctly outside TalkingBytes; +- [ ] temporary global runtime state is scoped/restored and cannot span user/Fiber suspension paths; +- [ ] elapsed-time/deadline logic uses monotonic time where appropriate; +- [ ] retry/watch/stream/process waits support cooperative cancellation where materially useful; +- [ ] mutable protocol/session/resilience state has explicit lifetime semantics; +- [ ] sequential and Fiber-interleaved isolation tests pass; +- [ ] webhook replay is atomic/fail-closed by contract and tests; +- [ ] no CacheLayer/Foundation/Omnibus runtime dependency was introduced; +- [ ] inbound gRPC has a host-controllable accepted-exchange boundary; +- [ ] inbound gRPC wire errors do not reveal internal exception classes/messages/traces; +- [ ] generated gRPC adapter no longer relies on broad TypeError execution probing; - [ ] native inbound/outbound email APIs remain authoritative; -- [ ] secret-sentinel tests pass across HTTP, webhook, gRPC, email and mailbox events/logging; +- [ ] sendmail timeout/cancellation/process-tree cleanup is deterministic; +- [ ] posix use is optional and pcntl is not required/default; +- [ ] HTTP multi scheduling uses a rolling concurrency window or the optimization is explicitly rejected with benchmark evidence; +- [ ] Foundation protocol-composition duplication has corresponding native TalkingBytes APIs ready for consumption; +- [ ] secret/PII sentinel tests pass across protocol observability; - [ ] unrelated optional capabilities remain cold until selected; -- [ ] native protocol benchmark evidence is recorded with correct attribution; -- [ ] PHPForge QA/static/security gates pass on the supported PHP/dependency matrix; +- [ ] native protocol benchmark and soak evidence is recorded; +- [ ] PHPForge QA/static/security gates pass on supported PHP/dependency matrices; - [ ] documentation builds warning-free; -- [ ] release metadata/examples match the final API; +- [ ] release metadata/examples match final APIs; - [ ] the exact final commit is tagged only after the complete matrix is green. --- -## 17. Explicitly Out of Scope +## 22. Explicitly Out of Scope Do not use 2.1 to add: - another universal communication envelope; - Foundation-specific service providers/configuration; -- CacheLayer, DBLayer or Omnibus as TalkingBytes runtime dependencies; +- CacheLayer, DBLayer or Omnibus runtime dependencies; - an HTTP application server/framework; - a general worker supervisor; +- a generic process-management package; +- fork-based HTTP/email concurrency; - a custom gRPC wire implementation; -- application-specific profile ownership; -- another major-version-scale redesign; -- unrelated feature expansion that does not improve TalkingBytes protocol correctness or the Foundation 3 integration boundary. +- application-specific named profile ownership; +- unrelated queue/messaging functionality; +- a major architectural rewrite that does not directly improve protocol correctness, runtime ownership, performance or Foundation integration. --- -## 18. Immediate Starting Point +## 23. Immediate Starting Point -Start with **Batch 1 — Runtime-state cleanup**. +Start with **Batch 1 — Runtime-state, clock and cancellation foundation**. -First objective: +First objectives: 1. remove direct CommunicationEventBus dependence from SpoolEmailReceiver, mailbox runtime paths and BounceParser; -2. propagate injected EventDispatcher objects through existing native factories; -3. add sequential persistent-runtime and Fiber isolation tests; -4. keep the static bus only as compatibility behavior. +2. propagate injected EventDispatcher objects through native factories; +3. introduce the minimal cancellation contract and interruptible wait behavior; +4. move duration/deadline logic toward Clock::monotonic(); +5. add sequential persistent-runtime and Fiber isolation tests. + +Then do the gRPC wire-error correction early because the current exception-class response metadata is a concrete boundary leak. -Do not modify Foundation during this first batch. TalkingBytes should first expose the clean lower-layer behavior; Foundation should consume the released result afterward. +Do not modify Foundation until TalkingBytes exposes the clean lower-layer APIs. Foundation should consume the released result afterward. From 67ce9537367dff927a125bacc106fe20a889fe0c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 15:33:06 +0600 Subject: [PATCH 03/51] feat: isolate runtime events and add cooperative cancellation --- docs/email/testing.rst | 10 +- docs/events.rst | 15 +- src/Core/Support/CancellationSignal.php | 34 +++++ src/Core/Support/RetryExecutor.php | 34 ++++- src/Core/Support/Sleeper.php | 44 +++++- src/Email/Email.php | 28 ++-- src/Email/EmailMailboxFactory.php | 25 +++- src/Email/EmailReceiverFactory.php | 16 +++ src/Email/EmailSenderFactory.php | 28 +++- src/Email/Emailer.php | 67 +++++---- src/Email/Mailbox/ImapSocketTransport.php | 38 +++-- src/Email/Mailbox/Mailbox.php | 13 +- src/Email/Mailbox/Pop3Mailbox.php | 13 +- src/Email/Mailbox/Pop3SocketTransport.php | 36 ++++- src/Email/Mailbox/SocketMailboxRuntime.php | 42 ++++-- src/Email/Parser/BounceParser.php | 15 +- src/Email/Receiver/SpoolEmailReceiver.php | 31 +++-- src/Email/Transport/RetryEmailTransport.php | 8 +- src/Grpc/GrpcClient.php | 13 +- src/Grpc/Middleware/RetryMiddleware.php | 24 +++- src/Http/HttpClient.php | 13 +- src/Http/Middleware/RetryMiddleware.php | 12 +- tests/BounceParserTest.php | 11 +- tests/RuntimeIsolationAndCancellationTest.php | 130 ++++++++++++++++++ 24 files changed, 580 insertions(+), 120 deletions(-) create mode 100644 src/Core/Support/CancellationSignal.php create mode 100644 tests/RuntimeIsolationAndCancellationTest.php 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/src/Core/Support/CancellationSignal.php b/src/Core/Support/CancellationSignal.php new file mode 100644 index 0000000..fa01c1a --- /dev/null +++ b/src/Core/Support/CancellationSignal.php @@ -0,0 +1,34 @@ +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); + } + + public function isRequested(): bool + { + return (bool) ($this->requested)(); + } +} diff --git a/src/Core/Support/RetryExecutor.php b/src/Core/Support/RetryExecutor.php index ebf5312..4ef92c8 100644 --- a/src/Core/Support/RetryExecutor.php +++ b/src/Core/Support/RetryExecutor.php @@ -18,11 +18,15 @@ 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 +35,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 +48,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/Email.php b/src/Email/Email.php index e8a2720..6e1f334 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,25 @@ 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, + ): EmailSenderFactory { + return new EmailSenderFactory($events, $clock); } } 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..52d8ba2 100644 --- a/src/Email/EmailSenderFactory.php +++ b/src/Email/EmailSenderFactory.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\LogEmailConfig; use Infocyph\TalkingBytes\Email\Config\SendmailConfig; use Infocyph\TalkingBytes\Email\Config\SmtpConfig; @@ -11,38 +15,48 @@ final readonly class EmailSenderFactory { + 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 fake(): Emailer { - return Emailer::fake(); + return Emailer::fake($this->events, $this->clock); } 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); + return Emailer::usingSendmail($config, $this->events, $this->clock); } 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); } } diff --git a/src/Email/Emailer.php b/src/Email/Emailer.php index c3da30a..e1b66f0 100644 --- a/src/Email/Emailer.php +++ b/src/Email/Emailer.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\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; use Infocyph\TalkingBytes\Email\Config\DkimConfig; use Infocyph\TalkingBytes\Email\Config\LogEmailConfig; use Infocyph\TalkingBytes\Email\Config\SendmailConfig; @@ -33,46 +35,55 @@ 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, + ): self { + return new self(new SendmailTransport($config), $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), $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 @@ -93,13 +104,13 @@ public function send(EmailMessage $message): CommunicationResult '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), + 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000), 'transport' => is_string($result->metadata['transport'] ?? null) ? $result->metadata['transport'] : null, @@ -116,7 +127,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 +135,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 +143,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 +153,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/ImapSocketTransport.php b/src/Email/Mailbox/ImapSocketTransport.php index b3c370f..2f82417 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; @@ -26,10 +31,23 @@ final class ImapSocketTransport implements BodyStructureMailboxTransport, Envelo private int $tagCounter = 1; + private readonly Clock $clock; + + private readonly EventDispatcher $events; + + private readonly Sleeper $sleeper; + 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() { @@ -461,10 +479,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 +541,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 +553,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 +619,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 +658,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 +672,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..c17cd36 100644 --- a/src/Email/Mailbox/Mailbox.php +++ b/src/Email/Mailbox/Mailbox.php @@ -4,6 +4,9 @@ namespace Infocyph\TalkingBytes\Email\Mailbox; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +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 +18,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 diff --git a/src/Email/Mailbox/Pop3Mailbox.php b/src/Email/Mailbox/Pop3Mailbox.php index 2d77a43..c723092 100644 --- a/src/Email/Mailbox/Pop3Mailbox.php +++ b/src/Email/Mailbox/Pop3Mailbox.php @@ -4,6 +4,9 @@ namespace Infocyph\TalkingBytes\Email\Mailbox; +use Infocyph\TalkingBytes\Core\Event\EventDispatcher; +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 +19,13 @@ public function __construct( private EmailParser $parser = new RawEmailParser(), ) {} - public static function usingConfig(Pop3Config $config): self - { - return new self(new Pop3SocketTransport($config)); + 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 delete(int $messageNumber): void diff --git a/src/Email/Mailbox/Pop3SocketTransport.php b/src/Email/Mailbox/Pop3SocketTransport.php index 2b9bd73..b6c088c 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; @@ -22,7 +27,22 @@ final class Pop3SocketTransport implements Pop3Transport */ private mixed $connection = null; - public function __construct(private readonly Pop3Config $config) {} + private readonly Clock $clock; + + private readonly EventDispatcher $events; + + private readonly Sleeper $sleeper; + + 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() { @@ -211,12 +231,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 +320,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 +392,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 +401,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..06c94a9 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), ]; } @@ -128,7 +139,8 @@ public static function readLine(mixed $connection, string $protocol, int $maxLen throw new MailboxConnectionException(sprintf('Failed to read from %s socket.', strtoupper($protocol))); } - if (!str_ends_with($line, "\n") && !feof($connection)) { + if (!str_ends_with($line, " +") && !feof($connection)) { throw new MailboxConnectionException(sprintf( '%s response line exceeds %d bytes.', strtoupper($protocol), @@ -180,4 +192,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/Receiver/SpoolEmailReceiver.php b/src/Email/Receiver/SpoolEmailReceiver.php index 160c15e..f45d088 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, @@ -255,8 +266,8 @@ 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 +278,12 @@ 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), + 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000), ]); return null; @@ -281,17 +292,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(), ]); - 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), + 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000), ]); return null; @@ -301,12 +312,12 @@ 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/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/Grpc/GrpcClient.php b/src/Grpc/GrpcClient.php index 8c41ad5..b006b87 100644 --- a/src/Grpc/GrpcClient.php +++ b/src/Grpc/GrpcClient.php @@ -8,6 +8,7 @@ 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\Grpc\Contract\GrpcMiddleware; use Infocyph\TalkingBytes\Grpc\Middleware\RetryMiddleware; use Infocyph\TalkingBytes\Grpc\Native\NativeGrpcInvoker; @@ -135,9 +136,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 @@ -156,9 +159,9 @@ public function withMiddlewares(array $middlewares): self return new self($this->transport, $middlewares, $this->streamingInvoker, $this->events); } - 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)); } /** 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/Http/HttpClient.php b/src/Http/HttpClient.php index f99a865..8a990ad 100644 --- a/src/Http/HttpClient.php +++ b/src/Http/HttpClient.php @@ -12,6 +12,7 @@ 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\Http\Body\MultipartBody; use Infocyph\TalkingBytes\Http\Contract\HttpMiddleware; use Infocyph\TalkingBytes\Http\Contract\HttpTransport; @@ -296,9 +297,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 +335,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/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/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/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]); +}); From abad84cdfb458b71e7c094fa73936c6f17bed732 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 15:37:25 +0600 Subject: [PATCH 04/51] ci: update PHPForge reusable workflow contract --- .github/workflows/security-standards.yml | 40 ++++++++++-------------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 5253626..d8a9d17 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -11,38 +11,29 @@ on: jobs: phpforge: uses: infocyph/phpforge/.github/workflows/security-standards.yml@main - 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_qa: true run_analysis: true - run_svg_report: true + upload_sarif: true + run_benchmark: true run_clean_install: true + run_svg_report: true + fail_on_skipped_tests: true + integration_services: '[]' + service_topologies: '{}' 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 + permissions: + security-events: write + actions: read + contents: read mailpit-integration: runs-on: ubuntu-latest @@ -55,20 +46,21 @@ 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 From 3eae5c3d64197947ef4452209ae6b73eb49262c0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 15:42:25 +0600 Subject: [PATCH 05/51] cleanup --- .github/workflows/security-standards.yml | 17 +------- captainhook.json | 55 ------------------------ 2 files changed, 1 insertion(+), 71 deletions(-) delete mode 100644 captainhook.json diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index d8a9d17..1bbf287 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -12,24 +12,9 @@ jobs: phpforge: uses: infocyph/phpforge/.github/workflows/security-standards.yml@main with: - php_versions: '["8.4","8.5"]' - dependency_versions: '["prefer-lowest","prefer-stable"]' - php_extensions: "curl, fileinfo, openssl" - run_qa: true - run_analysis: true - upload_sarif: true - run_benchmark: true - run_clean_install: true - run_svg_report: true - fail_on_skipped_tests: true integration_services: '[]' service_topologies: '{}' - benchmark_composer_script: "" - benchmark_result_file: "" - benchmark_baseline_file: "" - benchmark_max_regression_percent: 2 - benchmark_stable_environment: false - artifact_retention_days: 61 + php_extensions: "curl, fileinfo, openssl" permissions: security-events: write actions: read 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": [] - } -} From 3f8ec9a32e7df0da95ad68792b29fbc03a130318 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 15:50:39 +0600 Subject: [PATCH 06/51] fix: resolve Batch 1 CI and review findings --- src/Core/Support/CancellationSignal.php | 1 + src/Core/Support/RetryExecutor.php | 1 + src/Email/Mailbox/ImapSocketTransport.php | 12 +++++------ src/Email/Mailbox/Pop3SocketTransport.php | 12 +++++------ src/Email/Mailbox/SocketMailboxRuntime.php | 3 +-- tests/CharsetDecoderTest.php | 25 +++++++++++++--------- tests/MailboxImapTest.php | 22 ++++++++++--------- tests/Pop3MailboxTest.php | 22 ++++++++++--------- 8 files changed, 54 insertions(+), 44 deletions(-) diff --git a/src/Core/Support/CancellationSignal.php b/src/Core/Support/CancellationSignal.php index fa01c1a..e184b18 100644 --- a/src/Core/Support/CancellationSignal.php +++ b/src/Core/Support/CancellationSignal.php @@ -27,6 +27,7 @@ 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/RetryExecutor.php b/src/Core/Support/RetryExecutor.php index 4ef92c8..a2d94a7 100644 --- a/src/Core/Support/RetryExecutor.php +++ b/src/Core/Support/RetryExecutor.php @@ -27,6 +27,7 @@ public static function run( if ($cancellation?->isRequested() === true) { return self::cancelled($count - 1); } + try { $result = $attempt(); } catch (Throwable $throwable) { diff --git a/src/Email/Mailbox/ImapSocketTransport.php b/src/Email/Mailbox/ImapSocketTransport.php index 2f82417..8abdfb4 100644 --- a/src/Email/Mailbox/ImapSocketTransport.php +++ b/src/Email/Mailbox/ImapSocketTransport.php @@ -17,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 */ @@ -31,12 +37,6 @@ final class ImapSocketTransport implements BodyStructureMailboxTransport, Envelo private int $tagCounter = 1; - private readonly Clock $clock; - - private readonly EventDispatcher $events; - - private readonly Sleeper $sleeper; - public function __construct( private readonly ImapConfig $config, private readonly ImapResponseParser $responseParser = new ImapResponseParser(), diff --git a/src/Email/Mailbox/Pop3SocketTransport.php b/src/Email/Mailbox/Pop3SocketTransport.php index b6c088c..57e47f3 100644 --- a/src/Email/Mailbox/Pop3SocketTransport.php +++ b/src/Email/Mailbox/Pop3SocketTransport.php @@ -17,6 +17,12 @@ final class Pop3SocketTransport implements Pop3Transport { + private readonly Clock $clock; + + private readonly EventDispatcher $events; + + private readonly Sleeper $sleeper; + /** * @var list */ @@ -27,12 +33,6 @@ final class Pop3SocketTransport implements Pop3Transport */ private mixed $connection = null; - private readonly Clock $clock; - - private readonly EventDispatcher $events; - - private readonly Sleeper $sleeper; - public function __construct( private readonly Pop3Config $config, ?EventDispatcher $events = null, diff --git a/src/Email/Mailbox/SocketMailboxRuntime.php b/src/Email/Mailbox/SocketMailboxRuntime.php index 06c94a9..b8b938a 100644 --- a/src/Email/Mailbox/SocketMailboxRuntime.php +++ b/src/Email/Mailbox/SocketMailboxRuntime.php @@ -139,8 +139,7 @@ public static function readLine(mixed $connection, string $protocol, int $maxLen throw new MailboxConnectionException(sprintf('Failed to read from %s socket.', strtoupper($protocol))); } - if (!str_ends_with($line, " -") && !feof($connection)) { + if (!str_ends_with($line, "\n") && !feof($connection)) { throw new MailboxConnectionException(sprintf( '%s response line exceeds %d bytes.', strtoupper($protocol), diff --git a/tests/CharsetDecoderTest.php b/tests/CharsetDecoderTest.php index bc172e2..c83be72 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' => '中文'], diff --git a/tests/MailboxImapTest.php b/tests/MailboxImapTest.php index 221f712..bf12c36 100644 --- a/tests/MailboxImapTest.php +++ b/tests/MailboxImapTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use Infocyph\TalkingBytes\Core\Event\CallableEventDispatcher; 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; @@ -677,7 +677,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,18 +692,20 @@ 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, diff --git a/tests/Pop3MailboxTest.php b/tests/Pop3MailboxTest.php index 1b7a458..5876014 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; @@ -525,7 +525,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 +541,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, From dd03e72a5eab416bbc4a896ed5aeba7ff35dc61f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 15:53:45 +0600 Subject: [PATCH 07/51] feat: add host-controlled inbound gRPC boundary --- docs/grpc/inbound-outbound.rst | 35 +++++++- docs/grpc/testing.rst | 11 +++ src/Core/Event/BestEffortEventDispatcher.php | 7 ++ src/Grpc/GrpcInboundDispatcher.php | 58 +++++++++---- src/Grpc/Receiver/GrpcInboundExchange.php | 12 +++ src/Grpc/Receiver/GrpcInboundResponse.php | 10 +++ src/Grpc/Receiver/GrpcInboundSource.php | 12 +++ src/Grpc/Testing/FakeGrpcInboundExchange.php | 49 +++++++++++ src/Grpc/Testing/FakeGrpcInboundSource.php | 54 ++++++++++++ tests/GrpcServerTest.php | 88 +++++++++++++++++++- 10 files changed, 314 insertions(+), 22 deletions(-) create mode 100644 src/Grpc/Receiver/GrpcInboundExchange.php create mode 100644 src/Grpc/Receiver/GrpcInboundSource.php create mode 100644 src/Grpc/Testing/FakeGrpcInboundExchange.php create mode 100644 src/Grpc/Testing/FakeGrpcInboundSource.php diff --git a/docs/grpc/inbound-outbound.rst b/docs/grpc/inbound-outbound.rst index b081e17..b753103 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,37 @@ 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. + 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 +140,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/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/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/Grpc/GrpcInboundDispatcher.php b/src/Grpc/GrpcInboundDispatcher.php index c2b0730..a64e349 100644 --- a/src/Grpc/GrpcInboundDispatcher.php +++ b/src/Grpc/GrpcInboundDispatcher.php @@ -7,14 +7,18 @@ 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\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 +29,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 +51,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 +66,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 +75,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 +104,30 @@ 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; + } + + if ($cancellation?->isRequested() === true) { + $exchange->complete(GrpcInboundResponse::cancelled()); + + return true; + } + + $exchange->complete($this->handle($exchange->request())); + + return true; + } + /** * @param callable(GrpcInboundRequest):GrpcInboundResponse|GrpcInboundHandlerInterface $handler */ @@ -114,6 +140,6 @@ 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); } } 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 @@ +inboundRequest = $request; + } + + public function complete(GrpcInboundResponse $response): void + { + if ($this->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/tests/GrpcServerTest.php b/tests/GrpcServerTest.php index 5ae6958..b8191a7 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,77 @@ 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 + { + 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'); +}); From 2ac106fb19dd36e16b91e5aac54b76e25b861f73 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 15:56:49 +0600 Subject: [PATCH 08/51] fix: satisfy Batch 2 static quality gates --- src/Grpc/GrpcInboundDispatcher.php | 8 ++++++++ src/Grpc/Testing/FakeGrpcInboundExchange.php | 7 +------ tests/GrpcServerTest.php | 2 ++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Grpc/GrpcInboundDispatcher.php b/src/Grpc/GrpcInboundDispatcher.php index a64e349..52b7ae0 100644 --- a/src/Grpc/GrpcInboundDispatcher.php +++ b/src/Grpc/GrpcInboundDispatcher.php @@ -9,6 +9,7 @@ use Infocyph\TalkingBytes\Core\Event\EventDispatcher; 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; @@ -117,6 +118,13 @@ public function serveOne( return false; } + return $this->completeAcceptedExchange($exchange, $cancellation); + } + + private function completeAcceptedExchange( + GrpcInboundExchange $exchange, + ?CancellationSignal $cancellation, + ): bool { if ($cancellation?->isRequested() === true) { $exchange->complete(GrpcInboundResponse::cancelled()); diff --git a/src/Grpc/Testing/FakeGrpcInboundExchange.php b/src/Grpc/Testing/FakeGrpcInboundExchange.php index b29e486..7f90507 100644 --- a/src/Grpc/Testing/FakeGrpcInboundExchange.php +++ b/src/Grpc/Testing/FakeGrpcInboundExchange.php @@ -11,16 +11,11 @@ final class FakeGrpcInboundExchange implements GrpcInboundExchange { - private readonly GrpcInboundRequest $inboundRequest; - private bool $completed = false; private ?GrpcInboundResponse $response = null; - public function __construct(GrpcInboundRequest $request) - { - $this->inboundRequest = $request; - } + public function __construct(private readonly GrpcInboundRequest $inboundRequest) {} public function complete(GrpcInboundResponse $response): void { diff --git a/tests/GrpcServerTest.php b/tests/GrpcServerTest.php index b8191a7..22ddc6a 100644 --- a/tests/GrpcServerTest.php +++ b/tests/GrpcServerTest.php @@ -149,6 +149,8 @@ public function __construct(private readonly GrpcInboundExchange $exchange) {} public function accept(?CancellationSignal $cancellation = null): ?GrpcInboundExchange { + unset($cancellation); + return $this->exchange; } }; From 97de8c6214cb5831c6ed47762b534cddaad0541d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 15:58:41 +0600 Subject: [PATCH 09/51] style: order inbound dispatcher methods --- src/Grpc/GrpcInboundDispatcher.php | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/Grpc/GrpcInboundDispatcher.php b/src/Grpc/GrpcInboundDispatcher.php index 52b7ae0..182bcfa 100644 --- a/src/Grpc/GrpcInboundDispatcher.php +++ b/src/Grpc/GrpcInboundDispatcher.php @@ -121,6 +121,21 @@ public function serveOne( return $this->completeAcceptedExchange($exchange, $cancellation); } + /** + * @param callable(GrpcInboundRequest):GrpcInboundResponse|GrpcInboundHandlerInterface $handler + */ + public function withHandler(string $method, callable|GrpcInboundHandlerInterface $handler): self + { + $method = GrpcMethodGuard::normalize($method); + + $handlers = $this->handlers; + $handlers[$method] = $handler instanceof GrpcInboundHandlerInterface + ? $handler->handle(...) + : Closure::fromCallable($handler); + + return new self($handlers, $this->events, $this->clock); + } + private function completeAcceptedExchange( GrpcInboundExchange $exchange, ?CancellationSignal $cancellation, @@ -136,18 +151,4 @@ private function completeAcceptedExchange( return true; } - /** - * @param callable(GrpcInboundRequest):GrpcInboundResponse|GrpcInboundHandlerInterface $handler - */ - public function withHandler(string $method, callable|GrpcInboundHandlerInterface $handler): self - { - $method = GrpcMethodGuard::normalize($method); - - $handlers = $this->handlers; - $handlers[$method] = $handler instanceof GrpcInboundHandlerInterface - ? $handler->handle(...) - : Closure::fromCallable($handler); - - return new self($handlers, $this->events, $this->clock); - } } From f0b636c6b4e63c51a096d285833b1d326a45382c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:00:19 +0600 Subject: [PATCH 10/51] style: normalize inbound dispatcher separation --- src/Grpc/GrpcInboundDispatcher.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Grpc/GrpcInboundDispatcher.php b/src/Grpc/GrpcInboundDispatcher.php index 182bcfa..414363d 100644 --- a/src/Grpc/GrpcInboundDispatcher.php +++ b/src/Grpc/GrpcInboundDispatcher.php @@ -150,5 +150,4 @@ private function completeAcceptedExchange( return true; } - } From 2a6f019034dd2c7378fb23719d6f79254bc36895 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:13:14 +0600 Subject: [PATCH 11/51] feat: harden sendmail and mailbox runtime lifecycle --- composer.json | 1 + docs/email/outbound.rst | 30 ++ docs/extensions.rst | 3 +- src/Email/Email.php | 3 +- src/Email/EmailSenderFactory.php | 26 +- src/Email/Emailer.php | 14 +- src/Email/Mailbox/ImapSocketTransport.php | 36 ++- src/Email/Mailbox/Mailbox.php | 25 ++ src/Email/Mailbox/Pop3Mailbox.php | 26 ++ src/Email/Mailbox/Pop3SocketTransport.php | 20 +- src/Email/System/SendmailProcess.php | 342 +++++++++++++++++++++ src/Email/System/SendmailProcessResult.php | 14 + src/Email/Transport/SendmailTransport.php | 194 +++--------- tests/MailboxImapTest.php | 18 ++ tests/Pop3MailboxTest.php | 7 +- tests/TransportProcessCoverageTest.php | 20 ++ 16 files changed, 592 insertions(+), 187 deletions(-) create mode 100644 src/Email/System/SendmailProcess.php create mode 100644 src/Email/System/SendmailProcessResult.php 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/email/outbound.rst b/docs/email/outbound.rst index 6fa9bcf..2b97dd9 100644 --- a/docs/email/outbound.rst +++ b/docs/email/outbound.rst @@ -102,3 +102,33 @@ 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. diff --git a/docs/extensions.rst b/docs/extensions.rst index 7588730..4a97887 100644 --- a/docs/extensions.rst +++ b/docs/extensions.rst @@ -16,10 +16,11 @@ 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 Fallback behavior ----------------- -Some parser/mailbox helpers degrade gracefully when optional extensions are unavailable. Check test skips in CI logs to detect extension-limited environments. +Some parser/mailbox/process helpers degrade gracefully when optional extensions are unavailable. Sendmail process-group isolation is opportunistic; direct-child termination remains the portable fallback when POSIX functions are absent or cannot isolate the child. diff --git a/src/Email/Email.php b/src/Email/Email.php index 6e1f334..73f5f70 100644 --- a/src/Email/Email.php +++ b/src/Email/Email.php @@ -37,7 +37,8 @@ public static function receiver( public static function sender( ?EventDispatcher $events = null, ?Clock $clock = null, + ?Sleeper $sleeper = null, ): EmailSenderFactory { - return new EmailSenderFactory($events, $clock); + return new EmailSenderFactory($events, $clock, $sleeper); } } diff --git a/src/Email/EmailSenderFactory.php b/src/Email/EmailSenderFactory.php index 52d8ba2..774f933 100644 --- a/src/Email/EmailSenderFactory.php +++ b/src/Email/EmailSenderFactory.php @@ -7,7 +7,9 @@ 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\LogEmailConfig; use Infocyph\TalkingBytes\Email\Config\SendmailConfig; use Infocyph\TalkingBytes\Email\Config\SmtpConfig; @@ -19,10 +21,16 @@ private EventDispatcher $events; - public function __construct(?EventDispatcher $events = null, ?Clock $clock = null) - { + 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 @@ -45,9 +53,17 @@ public function usingNull(): Emailer return Emailer::usingNull($this->events, $this->clock); } - public function usingSendmail(SendmailConfig $config = new SendmailConfig()): Emailer - { - return Emailer::usingSendmail($config, $this->events, $this->clock); + 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 diff --git a/src/Email/Emailer.php b/src/Email/Emailer.php index e1b66f0..dc631a1 100644 --- a/src/Email/Emailer.php +++ b/src/Email/Emailer.php @@ -10,6 +10,7 @@ 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\DkimConfig; use Infocyph\TalkingBytes\Email\Config\LogEmailConfig; use Infocyph\TalkingBytes\Email\Config\SendmailConfig; @@ -72,8 +73,19 @@ 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), $events, $clock); + return new self( + new SendmailTransport( + $config, + cancellation: $cancellation, + clock: $clock, + sleeper: $sleeper, + ), + $events, + $clock, + ); } public static function usingSmtp(SmtpConfig $config, ?EventDispatcher $events = null, ?Clock $clock = null): self diff --git a/src/Email/Mailbox/ImapSocketTransport.php b/src/Email/Mailbox/ImapSocketTransport.php index 8abdfb4..4f947da 100644 --- a/src/Email/Mailbox/ImapSocketTransport.php +++ b/src/Email/Mailbox/ImapSocketTransport.php @@ -87,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 @@ -170,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 @@ -371,6 +374,17 @@ private function expectOk(ImapResponse $response, string $stage): ImapResponse )); } + private function closeConnection(): void + { + if (is_resource($this->connection)) { + fclose($this->connection); + } + + $this->connection = null; + $this->capabilities = []; + $this->selectedFolder = null; + } + private function hasCapability(string $capability): bool { return in_array(strtoupper($capability), $this->capabilities, true); diff --git a/src/Email/Mailbox/Mailbox.php b/src/Email/Mailbox/Mailbox.php index c17cd36..82bdf14 100644 --- a/src/Email/Mailbox/Mailbox.php +++ b/src/Email/Mailbox/Mailbox.php @@ -5,6 +5,7 @@ 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; @@ -62,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); @@ -104,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(); @@ -149,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/Pop3Mailbox.php b/src/Email/Mailbox/Pop3Mailbox.php index c723092..1caf1c5 100644 --- a/src/Email/Mailbox/Pop3Mailbox.php +++ b/src/Email/Mailbox/Pop3Mailbox.php @@ -5,6 +5,7 @@ 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; @@ -28,6 +29,11 @@ public static function usingConfig( return new self(new Pop3SocketTransport($config, $events, $clock, $sleeper)); } + public function connect(): void + { + $this->transport->connect(); + } + public function delete(int $messageNumber): void { Pop3MessageNumberGuard::assertValid($messageNumber); @@ -118,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 57e47f3..54281a1 100644 --- a/src/Email/Mailbox/Pop3SocketTransport.php +++ b/src/Email/Mailbox/Pop3SocketTransport.php @@ -72,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 diff --git a/src/Email/System/SendmailProcess.php b/src/Email/System/SendmailProcess.php new file mode 100644 index 0000000..81e083c --- /dev/null +++ b/src/Email/System/SendmailProcess.php @@ -0,0 +1,342 @@ + $pipes + */ + private function __construct( + private 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, + ) {} + + 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)) { + 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; + } + + $status = proc_get_status($this->process); + if (($status['running'] ?? false) === true) { + $this->terminate(); + } + + proc_close($this->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'] ?? false) !== true) { + $this->drainOutput(); + + $exitCode = is_int($status['exitcode'] ?? null) ? $status['exitcode'] : -1; + $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'] ?? false) !== true) { + throw new RuntimeException('Sendmail process exited while receiving the email payload.'); + } + + $this->sleeper->milliseconds(self::POLL_DELAY_MS); + + continue; + } + + $written += $current; + } + } + + 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; + } + + /** + * @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'], + ]; + } + + 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) + { + $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() + { + 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; + } + + $status = proc_get_status($this->process); + if (($status['running'] ?? false) !== true) { + return; + } + + $this->signal(self::GRACEFUL_SIGNAL); + $this->sleeper->milliseconds(self::TERMINATION_GRACE_MS); + + $status = proc_get_status($this->process); + if (($status['running'] ?? false) === true) { + $this->signal(self::FORCE_SIGNAL); + } + } + + /** + * @param resource $process + */ + private static function tryCreateProcessGroup($process): ?int + { + if ( + !function_exists('posix_setpgid') + || !function_exists('posix_getpgid') + || !function_exists('posix_kill') + ) { + return null; + } + + $status = proc_get_status($process); + $pid = is_int($status['pid'] ?? null) ? $status['pid'] : 0; + if ($pid < 1) { + return null; + } + + try { + if (!posix_setpgid($pid, $pid)) { + return null; + } + + return posix_getpgid($pid) === $pid ? $pid : null; + } catch (Throwable) { + return null; + } + } +} 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 @@ +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/tests/MailboxImapTest.php b/tests/MailboxImapTest.php index bf12c36..eaeda8d 100644 --- a/tests/MailboxImapTest.php +++ b/tests/MailboxImapTest.php @@ -3,6 +3,7 @@ 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\Enum\ImapSecurity; use Infocyph\TalkingBytes\Email\Exception\MailboxConnectionException; @@ -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(); diff --git a/tests/Pop3MailboxTest.php b/tests/Pop3MailboxTest.php index 5876014..ad2968b 100644 --- a/tests/Pop3MailboxTest.php +++ b/tests/Pop3MailboxTest.php @@ -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 { 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)); From 2dc1633b93b2c03776fc7b8b519265752c3af714 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:18:07 +0600 Subject: [PATCH 12/51] fix: normalize sendmail process lifecycle state --- src/Email/System/SendmailProcess.php | 190 +++++++++++++++------------ 1 file changed, 108 insertions(+), 82 deletions(-) diff --git a/src/Email/System/SendmailProcess.php b/src/Email/System/SendmailProcess.php index 81e083c..a04b6bf 100644 --- a/src/Email/System/SendmailProcess.php +++ b/src/Email/System/SendmailProcess.php @@ -22,6 +22,24 @@ final class SendmailProcess private const int TERMINATION_GRACE_MS = 100; + private readonly ?CancellationSignal $cancellation; + + private readonly Clock $clock; + + private readonly float $deadline; + + private readonly ?int $processGroupId; + + private readonly Sleeper $sleeper; + + private readonly int $timeoutSeconds; + + /** @var array */ + private array $pipes; + + /** @var resource|null */ + private mixed $process; + private string $stderr = ''; private string $stdout = ''; @@ -31,15 +49,24 @@ final class SendmailProcess * @param array $pipes */ private function __construct( - private 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, - ) {} + mixed $process, + array $pipes, + float $deadline, + int $timeoutSeconds, + Clock $clock, + Sleeper $sleeper, + ?CancellationSignal $cancellation, + ?int $processGroupId, + ) { + $this->cancellation = $cancellation; + $this->clock = $clock; + $this->deadline = $deadline; + $this->pipes = $pipes; + $this->process = $process; + $this->processGroupId = $processGroupId; + $this->sleeper = $sleeper; + $this->timeoutSeconds = $timeoutSeconds; + } public function __destruct() { @@ -58,24 +85,26 @@ public static function start( ): 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)) { - self::closePipeSet($pipes); - proc_terminate($process); - proc_close($process); - - throw new RuntimeException('Unable to configure sendmail process pipes.'); + 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( @@ -100,7 +129,7 @@ public function close(): void } $status = proc_get_status($this->process); - if (($status['running'] ?? false) === true) { + if ($status['running']) { $this->terminate(); } @@ -117,10 +146,10 @@ public function finish(): SendmailProcessResult $this->drainOutput(); $status = proc_get_status($this->requireProcess()); - if (($status['running'] ?? false) !== true) { + if (!$status['running']) { $this->drainOutput(); - $exitCode = is_int($status['exitcode'] ?? null) ? $status['exitcode'] : -1; + $exitCode = $status['exitcode']; $closeCode = proc_close($this->requireProcess()); $this->process = null; self::closePipeSet($this->pipes); @@ -158,7 +187,7 @@ public function write(string $chunk): void if ($current === 0) { $status = proc_get_status($this->requireProcess()); - if (($status['running'] ?? false) !== true) { + if (!$status['running']) { throw new RuntimeException('Sendmail process exited while receiving the email payload.'); } @@ -171,6 +200,60 @@ public function write(string $chunk): void } } + /** + * @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) { @@ -208,30 +291,6 @@ private function closeStdin(): void $this->pipes[0] = null; } - /** - * @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'], - ]; - } - private function drainOutput(): void { $stdout = $this->pipes[1] ?? null; @@ -248,7 +307,7 @@ private function drainOutput(): void /** * @return resource */ - private function pipe(int $index) + private function pipe(int $index): mixed { $pipe = $this->pipes[$index] ?? null; if (!is_resource($pipe)) { @@ -261,7 +320,7 @@ private function pipe(int $index) /** * @return resource */ - private function requireProcess() + private function requireProcess(): mixed { if (!is_resource($this->process)) { throw new RuntimeException('Sendmail process is not available.'); @@ -272,10 +331,7 @@ private function requireProcess() private function signal(int $signal): void { - if ( - $this->processGroupId !== null - && function_exists('posix_kill') - ) { + if ($this->processGroupId !== null && function_exists('posix_kill')) { try { if (posix_kill(-$this->processGroupId, $signal)) { return; @@ -297,7 +353,7 @@ private function terminate(): void } $status = proc_get_status($this->process); - if (($status['running'] ?? false) !== true) { + if (!$status['running']) { return; } @@ -305,38 +361,8 @@ private function terminate(): void $this->sleeper->milliseconds(self::TERMINATION_GRACE_MS); $status = proc_get_status($this->process); - if (($status['running'] ?? false) === true) { + if ($status['running']) { $this->signal(self::FORCE_SIGNAL); } } - - /** - * @param resource $process - */ - private static function tryCreateProcessGroup($process): ?int - { - if ( - !function_exists('posix_setpgid') - || !function_exists('posix_getpgid') - || !function_exists('posix_kill') - ) { - return null; - } - - $status = proc_get_status($process); - $pid = is_int($status['pid'] ?? null) ? $status['pid'] : 0; - if ($pid < 1) { - return null; - } - - try { - if (!posix_setpgid($pid, $pid)) { - return null; - } - - return posix_getpgid($pid) === $pid ? $pid : null; - } catch (Throwable) { - return null; - } - } } From 899b07004ab363161bfab82a72a3c7b8be59e804 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:18:13 +0600 Subject: [PATCH 13/51] style: order IMAP private lifecycle helpers --- src/Email/Mailbox/ImapSocketTransport.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Email/Mailbox/ImapSocketTransport.php b/src/Email/Mailbox/ImapSocketTransport.php index 4f947da..bb009d8 100644 --- a/src/Email/Mailbox/ImapSocketTransport.php +++ b/src/Email/Mailbox/ImapSocketTransport.php @@ -361,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()) { @@ -374,17 +385,6 @@ private function expectOk(ImapResponse $response, string $stage): ImapResponse )); } - private function closeConnection(): void - { - if (is_resource($this->connection)) { - fclose($this->connection); - } - - $this->connection = null; - $this->capabilities = []; - $this->selectedFolder = null; - } - private function hasCapability(string $capability): bool { return in_array(strtoupper($capability), $this->capabilities, true); From 908dc992e1b9b430fb084b2c283c5217fcc9f8b9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:20:50 +0600 Subject: [PATCH 14/51] fix: satisfy sendmail static lifecycle analysis --- src/Email/System/SendmailProcess.php | 46 ++++++++-------------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/src/Email/System/SendmailProcess.php b/src/Email/System/SendmailProcess.php index a04b6bf..a867391 100644 --- a/src/Email/System/SendmailProcess.php +++ b/src/Email/System/SendmailProcess.php @@ -22,21 +22,6 @@ final class SendmailProcess private const int TERMINATION_GRACE_MS = 100; - private readonly ?CancellationSignal $cancellation; - - private readonly Clock $clock; - - private readonly float $deadline; - - private readonly ?int $processGroupId; - - private readonly Sleeper $sleeper; - - private readonly int $timeoutSeconds; - - /** @var array */ - private array $pipes; - /** @var resource|null */ private mixed $process; @@ -50,22 +35,15 @@ final class SendmailProcess */ private function __construct( mixed $process, - array $pipes, - float $deadline, - int $timeoutSeconds, - Clock $clock, - Sleeper $sleeper, - ?CancellationSignal $cancellation, - ?int $processGroupId, + 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->cancellation = $cancellation; - $this->clock = $clock; - $this->deadline = $deadline; - $this->pipes = $pipes; $this->process = $process; - $this->processGroupId = $processGroupId; - $this->sleeper = $sleeper; - $this->timeoutSeconds = $timeoutSeconds; } public function __destruct() @@ -128,12 +106,13 @@ public function close(): void return; } - $status = proc_get_status($this->process); + $process = $this->process; + $status = proc_get_status($process); if ($status['running']) { $this->terminate(); } - proc_close($this->process); + proc_close($process); $this->process = null; } @@ -352,7 +331,8 @@ private function terminate(): void return; } - $status = proc_get_status($this->process); + $process = $this->process; + $status = proc_get_status($process); if (!$status['running']) { return; } @@ -360,7 +340,7 @@ private function terminate(): void $this->signal(self::GRACEFUL_SIGNAL); $this->sleeper->milliseconds(self::TERMINATION_GRACE_MS); - $status = proc_get_status($this->process); + $status = proc_get_status($process); if ($status['running']) { $this->signal(self::FORCE_SIGNAL); } From aa4457281ac285017b879039dfed9c1c14845e45 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:27:37 +0600 Subject: [PATCH 15/51] docs: sync plan through green Batch 3 --- ...1-foundation-integration-hardening-plan.md | 161 ++++++++++-------- 1 file changed, 87 insertions(+), 74 deletions(-) diff --git a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md index ab50fde..c053dae 100644 --- a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md +++ b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md @@ -10,9 +10,22 @@ Baseline: - implementation baseline branch: main - released baseline: 2.0.0 - baseline commit: 86d0e9dde8124ddeacea8ba7f81911af584b879b -- current planning head before this revision: 69a2d8f8c019358999a9fd48bf5edf42f51a914d +- current implementation head through Batch 3: 908dc992e1b9b430fb084b2c283c5217fcc9f8b9 - primary consumer: Foundation 3 runtime plan point 26.9 -- plan state: **PLANNED / RESCANNED** +- plan state: **ACTIVE — BATCHES 1–3 GREEN / BATCH 4 NEXT** + +Batch progress: + +- [x] Batch 1 — runtime-state, clock and cancellation foundation +- [x] Batch 2 — gRPC security and host boundary +- [x] Batch 3 — email runtime and sendmail process hardening +- [ ] Batch 4 — native protocol composition builders +- [ ] Batch 5 — webhook replay acceptance +- [ ] Batch 6 — HTTP rolling multi scheduler +- [ ] Batch 7 — gRPC generated adapter determinism +- [ ] Batch 8 — observability and data minimization +- [ ] Batch 9 — optional cold graphs, docs and benchmarks +- [ ] Batch 10 — exact-head release gate TalkingBytes 2.0 already established the intended protocol architecture. The 2.1 release should harden that architecture for persistent workers, Fibers, framework integration and high-throughput use while moving protocol composition out of Foundation where it currently leaks upward. @@ -301,19 +314,19 @@ Normal TalkingBytes object graphs must not depend on process-global mutable stat ### Tasks -- [ ] Propagate optional EventDispatcher dependencies through email sender/receiver/mailbox/parser factories where events are emitted. -- [ ] Convert SpoolEmailReceiver lifecycle events to injected dispatch. -- [ ] Convert mailbox command events away from direct CommunicationEventBus use. -- [ ] Convert BounceParser event emission away from direct CommunicationEventBus use. -- [ ] Audit every production src reference to CommunicationEventBus. -- [ ] Keep CommunicationEventBus only as a compatibility facade. -- [ ] Ensure new runtime code never requires the static bus. -- [ ] Keep dispatch best-effort: listener failures must not alter protocol results or cleanup. +- [x] Propagate optional EventDispatcher dependencies through email sender/receiver/mailbox/parser factories where events are emitted. +- [x] Convert SpoolEmailReceiver lifecycle events to injected dispatch. +- [x] Convert mailbox command events away from direct CommunicationEventBus use. +- [x] Convert BounceParser event emission away from direct CommunicationEventBus use. +- [x] Audit every production src reference to CommunicationEventBus. +- [x] Keep CommunicationEventBus only as a compatibility facade. +- [x] Ensure new runtime code never requires the static bus. +- [x] Keep dispatch best-effort: listener failures must not alter protocol results or cleanup. - [ ] Audit temporary set_error_handler regions. - [ ] Ensure no temporary global error handler spans arbitrary user callbacks, Fiber suspension, event dispatch or long-lived loops. -- [ ] Add sequential persistent-runtime tests proving event listeners and temporary runtime state do not leak. -- [ ] Add Fiber-interleaving tests for relevant stateless/object-scoped paths. -- [ ] Update events documentation to make injection authoritative. +- [x] Add sequential persistent-runtime tests proving event listeners and temporary runtime state do not leak. +- [x] Add Fiber-interleaving tests for relevant stateless/object-scoped paths. +- [x] Update events documentation to make injection authoritative. ### Acceptance @@ -345,14 +358,14 @@ Do not create a general task framework. - [ ] Standardize elapsed durations and internal deadlines on Core/Support/Clock::monotonic(). - [ ] Keep Clock::timestamp()/wall time only for protocol timestamps that require real time. -- [ ] Extend waiting support so retry/backoff sleeps can be interrupted in bounded slices when a cancellation signal is supplied. -- [ ] Keep the current simple Sleeper path cheap when no cancellation is supplied. -- [ ] Allow RetryExecutor to stop before the next attempt when cancelled. -- [ ] Allow HTTP retry and gRPC retry to stop before sleeping/retrying when cancelled. +- [x] Extend waiting support so retry/backoff sleeps can be interrupted in bounded slices when a cancellation signal is supplied. +- [x] Keep the current simple Sleeper path cheap when no cancellation is supplied. +- [x] Allow RetryExecutor to stop before the next attempt when cancelled. +- [x] Allow HTTP retry and gRPC retry to stop before sleeping/retrying when cancelled. - [ ] Allow WebhookSender retry to stop cooperatively. -- [ ] Allow mailbox watch loops to consume the same cancellation abstraction while retaining callable compatibility where practical. +- [x] Allow mailbox watch loops to consume the same cancellation abstraction while retaining callable compatibility where practical. - [ ] Allow generated/native gRPC streaming loops to check cancellation between messages/writes/reads where the native API permits. -- [ ] Allow the inbound gRPC accepted-call bridge to stop before accepting the next exchange. +- [x] Allow the inbound gRPC accepted-call bridge to stop before accepting the next exchange. - [ ] Allow CurlMultiTransport to stop scheduling and terminate/close active work safely when host cancellation is requested, if libcurl semantics permit deterministic cleanup. - [ ] Add deterministic fake-clock/fake-sleeper/cancellation tests. - [ ] Verify cancellation never skips required resource cleanup. @@ -391,10 +404,10 @@ Foundation adapts heartbeat loss, stop token and release-generation replacement - [ ] Prove CookieJar isolation. - [ ] Prove CircuitBreaker isolation. - [ ] Prove RateLimiter isolation. -- [ ] Prove mailbox connections are not shared accidentally across scoped graphs. +- [x] Prove mailbox connections are not shared accidentally across scoped graphs. - [ ] Document native gRPC stub/channel lifetime expectations. - [ ] Add sequential/Fiber tests around mutable collaborators. -- [ ] Do not introduce global resilience or native-client registries. +- [x] Do not introduce global resilience or native-client registries. - [ ] Ensure fake/spy state has deterministic new-instance/reset behavior. --- @@ -444,26 +457,26 @@ native/server runtime ### Required characteristics -- [ ] Add a small contract for accepting/obtaining one inbound gRPC exchange. -- [ ] Accepted exchange exposes normalized GrpcInboundRequest. -- [ ] TalkingBytes maps GrpcInboundResponse/status/metadata back to the native exchange. -- [ ] Provide a one-cycle or otherwise host-controllable execution API. +- [x] Add a small contract for accepting/obtaining one inbound gRPC exchange. +- [x] Accepted exchange exposes normalized GrpcInboundRequest. +- [x] TalkingBytes maps GrpcInboundResponse/status/metadata back to the native exchange. +- [x] Provide a one-cycle or otherwise host-controllable execution API. - [ ] Accept cancellation between calls and, where supported, during streams. -- [ ] Do not hide an uncontrolled infinite process loop. -- [ ] Preserve method normalization, metadata, deadline and status mapping. -- [ ] Add fake inbound source/exchange utilities. -- [ ] Do not add socket/process supervision. -- [ ] Do not require Foundation or Omnibus. +- [x] Do not hide an uncontrolled infinite process loop. +- [x] Preserve method normalization, metadata, deadline and status mapping. +- [x] Add fake inbound source/exchange utilities. +- [x] Do not add socket/process supervision. +- [x] Do not require Foundation or Omnibus. - [ ] Keep ext-grpc and grpc/grpc cold until selected. - [ ] Document exact inbound streaming modes actually implemented. - [ ] Keep inbound streaming incremental and bounded. ### Security correction -- [ ] Remove handler exception class from GrpcInboundResponse wire metadata. -- [ ] Return stable INTERNAL status/message only. -- [ ] Keep richer exception classification only in local events/logging when safe. -- [ ] Add a test proving remote responses do not reveal exception class, file path, trace or raw exception message. +- [x] Remove handler exception class from GrpcInboundResponse wire metadata. +- [x] Return stable INTERNAL status/message only. +- [x] Keep richer exception classification only in local events/logging when safe. +- [x] Add a test proving remote responses do not reveal exception class, file path, trace or raw exception message. --- @@ -471,42 +484,42 @@ native/server runtime ### Email runtime tasks -- [ ] Propagate injected EventDispatcher objects through EmailSenderFactory, EmailReceiverFactory and EmailMailboxFactory. -- [ ] Keep Emailer transport composition native to TalkingBytes. -- [ ] Keep SMTP/sendmail/mail/spool behavior native. -- [ ] Keep IMAP/POP3 behavior native. -- [ ] Keep MIME/parsing/DKIM/bounce behavior native. -- [ ] Define mailbox connection ownership and deterministic close/logout behavior. -- [ ] Ensure failed sessions cannot poison newly constructed instances. +- [x] Propagate injected EventDispatcher objects through EmailSenderFactory, EmailReceiverFactory and EmailMailboxFactory. +- [x] Keep Emailer transport composition native to TalkingBytes. +- [x] Keep SMTP/sendmail/mail/spool behavior native. +- [x] Keep IMAP/POP3 behavior native. +- [x] Keep MIME/parsing/DKIM/bounce behavior native. +- [x] Define mailbox connection ownership and deterministic close/logout behavior. +- [x] Ensure failed sessions cannot poison newly constructed instances. - [ ] Preserve bounded line/message/attachment/parser limits. - [ ] Preserve spool locking, quarantine and safe move semantics. -- [ ] Replace wall-clock logical deadlines with monotonic clock. -- [ ] Replace raw watch-loop sleeps with injectable waiting where useful. -- [ ] Keep IMAP IDLE cancellation responsive. -- [ ] Keep POP3 polling cancellation responsive. -- [ ] Add persistent-worker and cancellation tests. +- [x] Replace wall-clock logical deadlines with monotonic clock. +- [x] Replace raw watch-loop sleeps with injectable waiting where useful. +- [x] Keep IMAP IDLE cancellation responsive. +- [x] Keep POP3 polling cancellation responsive. +- [x] Add persistent-worker and cancellation tests. ### Sendmail subprocess tasks -- [ ] Keep command execution as an argument array and bypass the shell. -- [ ] Extract the private process loop into a narrow internal sendmail child-process helper if that reduces duplication/complexity. -- [ ] Use monotonic timeout. -- [ ] Add cooperative cancellation. -- [ ] Keep stdout/stderr capture bounded. -- [ ] Terminate gracefully, wait a bounded grace period, then force termination. -- [ ] When posix_setpgid/posix_getpgid/posix_kill are available and safe, place the child in its own process group and terminate the group so descendants are not orphaned. -- [ ] Fall back to direct proc_terminate when POSIX group control is unavailable. -- [ ] Do not require ext-posix. -- [ ] Do not require ext-pcntl. -- [ ] Do not import Foundation ProcessRunner or make TalkingBytes a generic process package. +- [x] Keep command execution as an argument array and bypass the shell. +- [x] Extract the private process loop into a narrow internal sendmail child-process helper if that reduces duplication/complexity. +- [x] Use monotonic timeout. +- [x] Add cooperative cancellation. +- [x] Keep stdout/stderr capture bounded. +- [x] Terminate gracefully, wait a bounded grace period, then force termination. +- [x] When posix_setpgid/posix_getpgid/posix_kill are available and safe, place the child in its own process group and terminate the group so descendants are not orphaned. +- [x] Fall back to direct proc_terminate when POSIX group control is unavailable. +- [x] Do not require ext-posix. +- [x] Do not require ext-pcntl. +- [x] Do not import Foundation ProcessRunner or make TalkingBytes a generic process package. - [ ] Add tests for timeout, cancellation, forced termination and cleanup. - [ ] Add optional Unix process-group coverage where CI supports it. - [ ] Verify Windows/non-POSIX fallback behavior remains valid. ### pcntl policy -- [ ] Do not register SIGINT/SIGTERM handlers inside SendmailTransport, SMTP, HTTP, webhook or gRPC normal paths. -- [ ] Foundation continues translating its worker signals into cancellation. +- [x] Do not register SIGINT/SIGTERM handlers inside SendmailTransport, SMTP, HTTP, webhook or gRPC normal paths. +- [x] Foundation continues translating its worker signals into cancellation. - [ ] Consider an explicit standalone PcntlSignalCancellation adapter only if a non-Foundation CLI use case justifies it. - [ ] If such an adapter is added, it must restore previous handlers and never become a default dependency path. @@ -668,16 +681,16 @@ Default events/log context must not expose secrets or unnecessary payload/PII. - [ ] Webhook works without native gRPC packages. - [ ] Basic outbound email works without IMAP-specific optional extensions. - [ ] SMTP works without ext-posix/ext-pcntl. -- [ ] Sendmail works with portable proc_* fallback when ext-posix is absent. -- [ ] POSIX process-group hardening activates only when functions are available. +- [x] Sendmail works with portable proc_* fallback when ext-posix is absent. +- [x] POSIX process-group hardening activates only when functions are available. - [ ] IMAP/POP3 optional checks occur only when selected. - [ ] RSA DKIM does not require Sodium. - [ ] Ed25519 DKIM fails clearly only when selected and Sodium is unavailable. - [ ] Native/generated gRPC fails clearly only when selected. - [ ] Composer suggest metadata matches actual optional behavior. -- [ ] Add ext-posix to suggest only if the released implementation actually uses it as an optional sendmail hardening path. -- [ ] Do not add ext-pcntl to suggest unless an explicit public pcntl adapter is shipped. -- [ ] Documentation matches Composer metadata. +- [x] Add ext-posix to suggest only if the released implementation actually uses it as an optional sendmail hardening path. +- [x] Do not add ext-pcntl to suggest unless an explicit public pcntl adapter is shipped. +- [x] Documentation matches Composer metadata. - [ ] Avoid unrelated extension/class probing on protocol hot paths. --- @@ -742,19 +755,19 @@ Foundation owns bridge attribution. ## 16. Documentation and Release Metadata - [ ] Update architecture docs with ownership/lifetime/cancellation boundaries. -- [ ] Update events docs: injected dispatcher primary; static bus compatibility-only. +- [x] Update events docs: injected dispatcher primary; static bus compatibility-only. - [ ] Update HTTP concurrency docs for rolling scheduling and cancellation semantics. - [ ] Update webhook replay docs with atomic/fail-closed requirements. -- [ ] Update gRPC inbound docs for the host-runtime bridge and wire-error data minimization. +- [x] Update gRPC inbound docs for the host-runtime bridge and wire-error data minimization. - [ ] Update gRPC generated/native docs for deterministic adapter behavior. -- [ ] Update email docs for persistent-worker connection ownership. -- [ ] Update sendmail docs for timeout/cancellation/POSIX optional behavior. +- [x] Update email docs for persistent-worker connection ownership. +- [x] Update sendmail docs for timeout/cancellation/POSIX optional behavior. - [ ] Update security docs with secret/PII redaction guarantees. - [ ] Update performance docs with persistent-runtime guidance. -- [ ] Update testing docs with isolation, fake cancellation and fake inbound-runtime examples. +- [x] Update testing docs with isolation, fake cancellation and fake inbound-runtime examples. - [ ] Update release checklist with static-state, monotonic-time, cancellation, optional-cold and secret-sentinel gates. - [ ] Keep README examples aligned with released APIs. -- [ ] Keep Composer requirements/suggestions synchronized with real runtime behavior. +- [x] Keep Composer requirements/suggestions synchronized with real runtime behavior. --- @@ -870,7 +883,7 @@ This is a planning map, not a requirement to modify every file. ## 19. Execution Order -### Batch 1 — Runtime-state, clock and cancellation foundation +### Batch 1 — Runtime-state, clock and cancellation foundation ✅ - remove primary static-event dependency; - add/propagate injected dispatch; @@ -878,7 +891,7 @@ This is a planning map, not a requirement to modify every file. - introduce minimal cooperative cancellation; - add persistent/Fiber isolation tests. -### Batch 2 — gRPC security and host boundary +### Batch 2 — gRPC security and host boundary ✅ - remove exception metadata leakage; - add accepted-exchange/source boundary; @@ -886,7 +899,7 @@ This is a planning map, not a requirement to modify every file. - add fake runtime; - preserve status/deadline/metadata semantics. -### Batch 3 — Email runtime and sendmail process hardening +### Batch 3 — Email runtime and sendmail process hardening ✅ - event injection; - mailbox/session ownership; @@ -895,7 +908,7 @@ This is a planning map, not a requirement to modify every file. - sendmail child-process supervision; - optional POSIX process-group safety. -### Batch 4 — Native protocol composition builders +### Batch 4 — Native protocol composition builders ⏳ - EmailLimits::fromArray; - HTTP resolved-profile builder; From df7021841d9ceab94ab9b959833f857f5215e285 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:38:48 +0600 Subject: [PATCH 16/51] feat: add resolved protocol composition builders --- docs/index.rst | 1 + docs/resolved-composition.rst | 80 +++++++++++ src/Email/Config/DkimConfig.php | 73 ++++++++++ src/Email/Config/EmailLimits.php | 18 +++ src/Email/EmailSenderFactory.php | 142 +++++++++++++++++++ src/Grpc/GrpcClient.php | 25 +++- src/Grpc/GrpcClientFactory.php | 186 +++++++++++++++++++++++++ src/Http/HttpClient.php | 21 ++- src/Http/HttpClientFactory.php | 198 ++++++++++++++++++++++++++ src/Webhook/Webhook.php | 176 ++++++++++++++++++++++++ tests/ResolvedCompositionTest.php | 221 ++++++++++++++++++++++++++++++ 11 files changed, 1135 insertions(+), 6 deletions(-) create mode 100644 docs/resolved-composition.rst create mode 100644 src/Grpc/GrpcClientFactory.php create mode 100644 src/Http/HttpClientFactory.php create mode 100644 tests/ResolvedCompositionTest.php 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/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/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/EmailSenderFactory.php b/src/Email/EmailSenderFactory.php index 774f933..cff7b18 100644 --- a/src/Email/EmailSenderFactory.php +++ b/src/Email/EmailSenderFactory.php @@ -10,10 +10,16 @@ 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 { @@ -38,6 +44,58 @@ public function fake(): Emailer 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, $this->events, $this->clock); @@ -75,4 +133,88 @@ public function usingSpool(SpoolConfig $config): Emailer { return Emailer::usingSpool($config, $this->events, $this->clock); } + + /** + * @param array $config + */ + 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/Grpc/GrpcClient.php b/src/Grpc/GrpcClient.php index b006b87..7c8756b 100644 --- a/src/Grpc/GrpcClient.php +++ b/src/Grpc/GrpcClient.php @@ -11,6 +11,7 @@ use Infocyph\TalkingBytes\Core\Support\CancellationSignal; 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; @@ -50,8 +51,20 @@ public static function using(callable $caller, ?EventDispatcher $events = null): return new self(new GrpcTransport($caller, $events), events: $events); } - public static function usingNative(NativeGrpcInvoker $invoker): self - { + public static function usingGeneratedStub( + object $stubClient, + array $methodMap = [], + ?EventDispatcher $events = null, + ): self { + $invoker = new GeneratedStubGrpcInvoker($stubClient, $methodMap); + + return self::usingNativeStreaming($invoker, $invoker, $events); + } + + public static function usingNative( + NativeGrpcInvoker $invoker, + ?EventDispatcher $events = null, + ): self { return self::using( static function (GrpcRequest $request) use ($invoker): GrpcResponse { $native = $invoker->invoke( @@ -69,14 +82,20 @@ static function (GrpcRequest $request) use ($invoker): GrpcResponse { metadata: $native->metadata, ); }, + $events, ); } public static function usingNativeStreaming( NativeGrpcInvoker $invoker, NativeGrpcStreamingInvoker $streamingInvoker, + ?EventDispatcher $events = null, ): self { - return new self(self::usingNative($invoker)->transport, streamingInvoker: $streamingInvoker); + return new self( + self::usingNative($invoker, $events)->transport, + streamingInvoker: $streamingInvoker, + events: $events, + ); } /** diff --git a/src/Grpc/GrpcClientFactory.php b/src/Grpc/GrpcClientFactory.php new file mode 100644 index 0000000..7accc2a --- /dev/null +++ b/src/Grpc/GrpcClientFactory.php @@ -0,0 +1,186 @@ + $config + */ + public function using(callable $caller, array $config = []): GrpcClient + { + return $this->applyResolvedConfig( + GrpcClient::using($caller, $this->events), + $config, + ); + } + + /** + * @param array $config + */ + public function usingGeneratedStub( + object $stubClient, + array $methodMap = [], + array $config = [], + ): GrpcClient { + return $this->applyResolvedConfig( + GrpcClient::usingGeneratedStub($stubClient, $methodMap, $this->events), + $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) + : GrpcClient::usingNative($invoker, $this->events); + + 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 ($value === null) { + return []; + } + + 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/Http/HttpClient.php b/src/Http/HttpClient.php index 8a990ad..823dae2 100644 --- a/src/Http/HttpClient.php +++ b/src/Http/HttpClient.php @@ -64,10 +64,13 @@ 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, + ): self { return new self( - transport: new CurlTransport(), + transport: $transport ?? new CurlTransport($events), defaultOptions: new CurlOptions( timeoutSeconds: $config->timeoutSeconds, connectTimeoutSeconds: $config->connectTimeoutSeconds, @@ -87,6 +90,18 @@ public static function fromConfig(HttpClientConfig $config): self ); } + /** + * @param array $config + */ + public static function fromResolvedConfig( + array $config, + ?EventDispatcher $events = null, + ?CancellationSignal $cancellation = null, + ?HttpTransport $transport = null, + ): self { + return (new HttpClientFactory($events, $cancellation))->fromArray($config, $transport); + } + public static function multi(int $maxConcurrency = 10, ?EventDispatcher $events = null): Concurrent\RequestPool { return new Concurrent\RequestPool(new Concurrent\CurlMultiTransport(events: $events), $maxConcurrency); diff --git a/src/Http/HttpClientFactory.php b/src/Http/HttpClientFactory.php new file mode 100644 index 0000000..72041a0 --- /dev/null +++ b/src/Http/HttpClientFactory.php @@ -0,0 +1,198 @@ + $config + */ + public function fromArray(array $config, ?HttpTransport $transport = null): HttpClient + { + $client = HttpClient::fromConfig( + HttpClientConfig::fromArray($config), + $this->events, + $transport, + ); + + $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 ($value === null) { + return []; + } + + 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/Webhook/Webhook.php b/src/Webhook/Webhook.php index 9524eb2..0956218 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,186 @@ 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 ($value === null) { + return []; + } + + 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/tests/ResolvedCompositionTest.php b/tests/ResolvedCompositionTest.php new file mode 100644 index 0000000..f17ad3e --- /dev/null +++ b/tests/ResolvedCompositionTest.php @@ -0,0 +1,221 @@ + 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])); + + $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); +}); From c268aae255ba4235c0b9bd4d4fb31ac2fbbb71e6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:44:19 +0600 Subject: [PATCH 17/51] fix: satisfy Batch 4 safety and quality gates --- src/Email/EmailSenderFactory.php | 1 - src/Grpc/GrpcClientFactory.php | 2 +- src/Http/HttpClient.php | 2 +- src/Http/HttpClientFactory.php | 2 +- src/Webhook/Webhook.php | 6 ++++-- tests/ResolvedCompositionTest.php | 5 ++++- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Email/EmailSenderFactory.php b/src/Email/EmailSenderFactory.php index cff7b18..ba2b36c 100644 --- a/src/Email/EmailSenderFactory.php +++ b/src/Email/EmailSenderFactory.php @@ -44,7 +44,6 @@ public function fake(): Emailer return Emailer::fake($this->events, $this->clock); } - /** * Build an email sender from already-resolved protocol configuration. * diff --git a/src/Grpc/GrpcClientFactory.php b/src/Grpc/GrpcClientFactory.php index 7accc2a..4c898af 100644 --- a/src/Grpc/GrpcClientFactory.php +++ b/src/Grpc/GrpcClientFactory.php @@ -160,6 +160,7 @@ private static function section(array $config, string $key): array return $section; } + /** * @param array $config */ @@ -182,5 +183,4 @@ private function applyResolvedConfig(GrpcClient $client, array $config): GrpcCli $this->cancellation, ); } - } diff --git a/src/Http/HttpClient.php b/src/Http/HttpClient.php index 823dae2..898e1e7 100644 --- a/src/Http/HttpClient.php +++ b/src/Http/HttpClient.php @@ -99,7 +99,7 @@ public static function fromResolvedConfig( ?CancellationSignal $cancellation = null, ?HttpTransport $transport = null, ): self { - return (new HttpClientFactory($events, $cancellation))->fromArray($config, $transport); + return new HttpClientFactory($events, $cancellation)->fromArray($config, $transport); } public static function multi(int $maxConcurrency = 10, ?EventDispatcher $events = null): Concurrent\RequestPool diff --git a/src/Http/HttpClientFactory.php b/src/Http/HttpClientFactory.php index 72041a0..4274cc8 100644 --- a/src/Http/HttpClientFactory.php +++ b/src/Http/HttpClientFactory.php @@ -171,6 +171,7 @@ private static function string( return $value; } + /** * @param array $auth */ @@ -194,5 +195,4 @@ private function applyAuth(HttpClient $client, array $auth): HttpClient default => throw new InvalidArgumentException('Unsupported HTTP auth driver.'), }; } - } diff --git a/src/Webhook/Webhook.php b/src/Webhook/Webhook.php index 0956218..93b6063 100644 --- a/src/Webhook/Webhook.php +++ b/src/Webhook/Webhook.php @@ -30,7 +30,8 @@ public static function receiver(#[\SensitiveParameter] string|array $secret, int * @param array $config */ public static function receiverFromResolvedConfig( - #[\SensitiveParameter] string|array $secret, + #[\SensitiveParameter] + string|array $secret, array $config, ?WebhookReplayStore $replayStore = null, ?EventDispatcher $events = null, @@ -112,7 +113,8 @@ public static function verifier(#[\SensitiveParameter] string|array $secret, int * @param array $config */ public static function verifierFromResolvedConfig( - #[\SensitiveParameter] string|array $secret, + #[\SensitiveParameter] + string|array $secret, array $config, ?EventDispatcher $events = null, ): WebhookVerifier { diff --git a/tests/ResolvedCompositionTest.php b/tests/ResolvedCompositionTest.php index f17ad3e..458ba75 100644 --- a/tests/ResolvedCompositionTest.php +++ b/tests/ResolvedCompositionTest.php @@ -141,7 +141,10 @@ static function (GrpcRequest $request) use (&$attempts): GrpcResponse { ], ); - $result = $client->send(new GrpcRequest('/orders.v1.OrderService/Create', ['id' => 1])); + $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 From e44c0376fc072c26c1ce54f97d42ba8cde9c8e9b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:45:31 +0600 Subject: [PATCH 18/51] fix: align Batch 4 with PHPForge conventions From fead8b969a5ae09d228c13f2c90e39f5c6369757 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:46:52 +0600 Subject: [PATCH 19/51] fix: tighten resolved composition types --- src/Email/EmailSenderFactory.php | 1 + src/Grpc/GrpcClient.php | 3 +++ src/Grpc/GrpcClientFactory.php | 5 +---- src/Http/HttpClientFactory.php | 4 ---- src/Webhook/Webhook.php | 4 ---- 5 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/Email/EmailSenderFactory.php b/src/Email/EmailSenderFactory.php index ba2b36c..73eee5b 100644 --- a/src/Email/EmailSenderFactory.php +++ b/src/Email/EmailSenderFactory.php @@ -135,6 +135,7 @@ public function usingSpool(SpoolConfig $config): Emailer /** * @param array $config + * @return array */ private static function section(array $config, string $key, bool $required = false): array { diff --git a/src/Grpc/GrpcClient.php b/src/Grpc/GrpcClient.php index 7c8756b..4b9af39 100644 --- a/src/Grpc/GrpcClient.php +++ b/src/Grpc/GrpcClient.php @@ -51,6 +51,9 @@ public static function using(callable $caller, ?EventDispatcher $events = null): return new self(new GrpcTransport($caller, $events), events: $events); } + /** + * @param array $methodMap + */ public static function usingGeneratedStub( object $stubClient, array $methodMap = [], diff --git a/src/Grpc/GrpcClientFactory.php b/src/Grpc/GrpcClientFactory.php index 4c898af..e732e34 100644 --- a/src/Grpc/GrpcClientFactory.php +++ b/src/Grpc/GrpcClientFactory.php @@ -33,6 +33,7 @@ public function using(callable $caller, array $config = []): GrpcClient } /** + * @param array $methodMap * @param array $config */ public function usingGeneratedStub( @@ -143,10 +144,6 @@ private static function nullableInt(mixed $value, string $key): ?int private static function section(array $config, string $key): array { $value = $config[$key] ?? []; - if ($value === null) { - return []; - } - if (!is_array($value)) { throw new InvalidArgumentException(sprintf('gRPC resolved configuration section "%s" must be an array.', $key)); } diff --git a/src/Http/HttpClientFactory.php b/src/Http/HttpClientFactory.php index 4274cc8..9d0f412 100644 --- a/src/Http/HttpClientFactory.php +++ b/src/Http/HttpClientFactory.php @@ -134,10 +134,6 @@ private static function int(array $config, string $key, int $default): int private static function section(array $config, string $key): array { $value = $config[$key] ?? []; - if ($value === null) { - return []; - } - if (!is_array($value)) { throw new InvalidArgumentException(sprintf('HTTP resolved configuration section "%s" must be an array.', $key)); } diff --git a/src/Webhook/Webhook.php b/src/Webhook/Webhook.php index 93b6063..1ab5ee2 100644 --- a/src/Webhook/Webhook.php +++ b/src/Webhook/Webhook.php @@ -176,10 +176,6 @@ private static function int(array $config, string $key, int $default): int private static function section(array $config, string $key): array { $value = $config[$key] ?? []; - if ($value === null) { - return []; - } - if (!is_array($value)) { throw new InvalidArgumentException(sprintf('Webhook resolved configuration section "%s" must be an array.', $key)); } From 2e67cff910f650039b92b044d46a2886b1dce4c7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:49:10 +0600 Subject: [PATCH 20/51] docs: mark green Batch 4 composition work --- ...1-foundation-integration-hardening-plan.md | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md index c053dae..b2456e6 100644 --- a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md +++ b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md @@ -12,14 +12,14 @@ Baseline: - baseline commit: 86d0e9dde8124ddeacea8ba7f81911af584b879b - current implementation head through Batch 3: 908dc992e1b9b430fb084b2c283c5217fcc9f8b9 - primary consumer: Foundation 3 runtime plan point 26.9 -- plan state: **ACTIVE — BATCHES 1–3 GREEN / BATCH 4 NEXT** +- plan state: **ACTIVE — BATCHES 1–4 GREEN / BATCH 5 NEXT** Batch progress: - [x] Batch 1 — runtime-state, clock and cancellation foundation - [x] Batch 2 — gRPC security and host boundary - [x] Batch 3 — email runtime and sendmail process hardening -- [ ] Batch 4 — native protocol composition builders +- [x] Batch 4 — native protocol composition builders - [ ] Batch 5 — webhook replay acceptance - [ ] Batch 6 — HTTP rolling multi scheduler - [ ] Batch 7 — gRPC generated adapter determinism @@ -539,12 +539,12 @@ Prefer extending existing factories/facades before adding many new abstractions. Move the mechanics currently in Foundation CommunicationProfiles::decorateHttp into a TalkingBytes-native builder/factory: -- [ ] auth driver composition; -- [ ] CookieJar opt-in; -- [ ] retry policy composition; -- [ ] RateLimiter composition; -- [ ] CircuitBreaker composition; -- [ ] idempotency middleware composition. +- [x] auth driver composition; +- [x] CookieJar opt-in; +- [x] retry policy composition; +- [x] RateLimiter composition; +- [x] CircuitBreaker composition; +- [x] idempotency middleware composition. Foundation should still: @@ -555,33 +555,33 @@ Foundation should still: ### gRPC composition -- [ ] Add a direct TalkingBytes convenience path for generated stubs so Foundation does not construct GeneratedStubGrpcInvoker itself unless it needs customization. -- [ ] Centralize native/generated/streaming client composition in TalkingBytes. -- [ ] Centralize gRPC retry-profile application in TalkingBytes. -- [ ] Allow EventDispatcher injection through usingNative/usingNativeStreaming/generated-stub paths. -- [ ] Keep service/handler lookup in Foundation. +- [x] Add a direct TalkingBytes convenience path for generated stubs so Foundation does not construct GeneratedStubGrpcInvoker itself unless it needs customization. +- [x] Centralize native/generated/streaming client composition in TalkingBytes. +- [x] Centralize gRPC retry-profile application in TalkingBytes. +- [x] Allow EventDispatcher injection through usingNative/usingNativeStreaming/generated-stub paths. +- [x] Keep service/handler lookup in Foundation. ### Webhook composition -- [ ] Keep signing, verifier/receiver creation and retry-profile mechanics in TalkingBytes. -- [ ] Allow a resolved outbound/inbound config array or small typed config to be applied without Foundation recreating protocol rules. -- [ ] Keep secret source resolution and production-secret policy in Foundation. -- [ ] Keep replay-store implementation in Foundation. +- [x] Keep signing, verifier/receiver creation and retry-profile mechanics in TalkingBytes. +- [x] Allow a resolved outbound/inbound config array or small typed config to be applied without Foundation recreating protocol rules. +- [x] Keep secret source resolution and production-secret policy in Foundation. +- [x] Keep replay-store implementation in Foundation. ### Email composition Expand native email factory capability so Foundation no longer has to own protocol transport/decorator mechanics: -- [ ] transport driver creation from resolved transport config; -- [ ] fallback transport composition; -- [ ] retry policy composition; -- [ ] rate-limit composition; -- [ ] DKIM config/application after path/secret resolution; -- [ ] parser-limit parsing. +- [x] transport driver creation from resolved transport config; +- [x] fallback transport composition; +- [x] retry policy composition; +- [x] rate-limit composition; +- [x] DKIM config/application after path/secret resolution; +- [x] parser-limit parsing. Specific easy win: -- [ ] add EmailLimits::fromArray() using TalkingBytes-native strict config parsing so Foundation NotificationGraphFactory does not duplicate EmailLimits construction. +- [x] add EmailLimits::fromArray() using TalkingBytes-native strict config parsing so Foundation NotificationGraphFactory does not duplicate EmailLimits construction. Foundation should still: @@ -908,7 +908,7 @@ This is a planning map, not a requirement to modify every file. - sendmail child-process supervision; - optional POSIX process-group safety. -### Batch 4 — Native protocol composition builders ⏳ +### Batch 4 — Native protocol composition builders ✅ - EmailLimits::fromArray; - HTTP resolved-profile builder; @@ -917,7 +917,7 @@ This is a planning map, not a requirement to modify every file. - webhook resolved-policy composition; - tests proving Foundation no longer needs to recreate protocol mechanics. -### Batch 5 — Webhook replay acceptance +### Batch 5 — Webhook replay acceptance ⏳ - atomic/fail-closed contract; - contention/error tests; From c9db21c8ec78f8a5e82a1a976c870920f7963c16 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:49:53 +0600 Subject: [PATCH 21/51] test: harden webhook replay acceptance contract --- docs/webhook/replay.rst | 11 ++- src/Webhook/Contracts/WebhookReplayStore.php | 4 +- .../Replay/InMemoryWebhookReplayStore.php | 5 ++ tests/WebhookReceiverTest.php | 75 +++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/docs/webhook/replay.rst b/docs/webhook/replay.rst index fa5654d..332738f 100644 --- a/docs/webhook/replay.rst +++ b/docs/webhook/replay.rst @@ -31,5 +31,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/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/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/tests/WebhookReceiverTest.php b/tests/WebhookReceiverTest.php index 1f2dbd5..3d958cc 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; @@ -98,3 +99,77 @@ 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'); +}); From 4d0be696b99ff6c03ab7232ff24dcb24623d40b8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 16:52:00 +0600 Subject: [PATCH 22/51] docs: mark green Batch 5 replay acceptance --- ...1-foundation-integration-hardening-plan.md | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md index b2456e6..ffe294d 100644 --- a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md +++ b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md @@ -12,7 +12,7 @@ Baseline: - baseline commit: 86d0e9dde8124ddeacea8ba7f81911af584b879b - current implementation head through Batch 3: 908dc992e1b9b430fb084b2c283c5217fcc9f8b9 - primary consumer: Foundation 3 runtime plan point 26.9 -- plan state: **ACTIVE — BATCHES 1–4 GREEN / BATCH 5 NEXT** +- plan state: **ACTIVE — BATCHES 1–5 GREEN / BATCH 6 NEXT** Batch progress: @@ -20,7 +20,7 @@ Batch progress: - [x] Batch 2 — gRPC security and host boundary - [x] Batch 3 — email runtime and sendmail process hardening - [x] Batch 4 — native protocol composition builders -- [ ] Batch 5 — webhook replay acceptance +- [x] Batch 5 — webhook replay acceptance - [ ] Batch 6 — HTTP rolling multi scheduler - [ ] Batch 7 — gRPC generated adapter determinism - [ ] Batch 8 — observability and data minimization @@ -420,18 +420,18 @@ Replay protection remains protocol-owned and storage-provider-neutral. ### Tasks -- [ ] Keep WebhookReplayStore minimal. -- [ ] Document that production claim must be atomic across competing processes. -- [ ] Document backend errors as fail-closed. -- [ ] Add a contention contract test where only one contender wins. -- [ ] Add a throwing-store test proving replay protection is not bypassed. -- [ ] Preserve strict positive TTL validation. -- [ ] Preserve bounded namespace/delivery-ID validation. -- [ ] Preserve signature/timestamp verification before replay claim. -- [ ] Preserve replay claim before a verified event is returned. -- [ ] Mark InMemoryWebhookReplayStore clearly as single-process/test/local-use unless its guarantees are sufficient for the documented deployment. -- [ ] Ensure replay observability never exposes raw secret/signature/body. -- [ ] Do not add CacheLayer. +- [x] Keep WebhookReplayStore minimal. +- [x] Document that production claim must be atomic across competing processes. +- [x] Document backend errors as fail-closed. +- [x] Add a contention contract test where only one contender wins. +- [x] Add a throwing-store test proving replay protection is not bypassed. +- [x] Preserve strict positive TTL validation. +- [x] Preserve bounded namespace/delivery-ID validation. +- [x] Preserve signature/timestamp verification before replay claim. +- [x] Preserve replay claim before a verified event is returned. +- [x] Mark InMemoryWebhookReplayStore clearly as single-process/test/local-use unless its guarantees are sufficient for the documented deployment. +- [x] Ensure replay observability never exposes raw secret/signature/body. +- [x] Do not add CacheLayer. ### Foundation handoff @@ -917,13 +917,13 @@ This is a planning map, not a requirement to modify every file. - webhook resolved-policy composition; - tests proving Foundation no longer needs to recreate protocol mechanics. -### Batch 5 — Webhook replay acceptance ⏳ +### Batch 5 — Webhook replay acceptance ✅ - atomic/fail-closed contract; - contention/error tests; - preserve provider neutrality. -### Batch 6 — HTTP rolling multi scheduler +### Batch 6 — HTTP rolling multi scheduler ⏳ - rolling window; - stop-scheduling behavior; From 527c14515829701701a68620b946346a472268f6 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 17:13:50 +0600 Subject: [PATCH 23/51] feat: add rolling cancellable HTTP multi scheduler --- docs/http/concurrency.rst | 19 +- src/Http/Concurrent/CurlMultiTransport.php | 480 ++++++++++++++++---- src/Http/Concurrent/RequestPool.php | 33 +- src/Http/HttpClient.php | 13 +- src/Http/Internal/ResponseBodyCollector.php | 15 + tests/HttpConcurrentPoolTest.php | 37 ++ 6 files changed, 487 insertions(+), 110 deletions(-) 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/src/Http/Concurrent/CurlMultiTransport.php b/src/Http/Concurrent/CurlMultiTransport.php index dd692b9..dac8d82 100644 --- a/src/Http/Concurrent/CurlMultiTransport.php +++ b/src/Http/Concurrent/CurlMultiTransport.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\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Http\HttpRequest; use Infocyph\TalkingBytes\Http\Internal\CurlHandleConfigurator; @@ -21,21 +23,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 +56,206 @@ 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, $requests, $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, + $requests, + $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, $requests, $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 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 array $requests + * @param list $keys + * @param array $contexts * @param array $results */ - private function containsFailure(array $results): bool + private function cancelOutstanding( + \CurlMultiHandle $multiHandle, + array $requests, + 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']; + $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 static function cancelledResult(bool $started): CommunicationResult { - return array_any($results, static fn(CommunicationResult $result): bool => !$result->successful); + return CommunicationResult::failure( + 'HTTP concurrent operation cancelled.', + metadata: [ + 'transport' => 'curl-multi', + 'cancelled' => true, + 'started' => $started, + ], + ); } private function dispatchRequestResultEvent(HttpRequest $request, CommunicationResult $result): void @@ -113,7 +270,93 @@ private function dispatchRequestResultEvent(HttpRequest $request, CommunicationR } /** - * @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' => $running]; + } + + /** + * @param array $requests + * @param list $keys + * @param array $contexts + * @param array $results + */ + private function failOutstanding( + \CurlMultiHandle $multiHandle, + array $requests, + 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 $requests + * @param array $results + */ + 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 array{key:int|string, handle:\CurlHandle, request:HttpRequest, headerCollector:ResponseHeaderCollector, bodyCollector:ResponseBodyCollector} $context */ private function finalizeContext(array $context): CommunicationResult { @@ -122,8 +365,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, @@ -160,9 +401,26 @@ private function finalizeContext(array $context): CommunicationResult return $result; } + /** + * @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 + * @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 898e1e7..65d3ad9 100644 --- a/src/Http/HttpClient.php +++ b/src/Http/HttpClient.php @@ -102,9 +102,16 @@ public static function fromResolvedConfig( return new HttpClientFactory($events, $cancellation)->fromArray($config, $transport); } - public static function multi(int $maxConcurrency = 10, ?EventDispatcher $events = null): Concurrent\RequestPool - { - return new Concurrent\RequestPool(new Concurrent\CurlMultiTransport(events: $events), $maxConcurrency); + 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 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/tests/HttpConcurrentPoolTest.php b/tests/HttpConcurrentPoolTest.php index fccdc42..ea44614 100644 --- a/tests/HttpConcurrentPoolTest.php +++ b/tests/HttpConcurrentPoolTest.php @@ -3,6 +3,7 @@ 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; @@ -72,3 +73,39 @@ 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(); + } +}); From 68058ff4bd01879994efa27e7ff2887b2a357fe8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 17:17:04 +0600 Subject: [PATCH 24/51] fix: satisfy rolling scheduler quality gates --- src/Http/Concurrent/CurlMultiTransport.php | 41 +++++++++++----------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/Http/Concurrent/CurlMultiTransport.php b/src/Http/Concurrent/CurlMultiTransport.php index dac8d82..7de19a5 100644 --- a/src/Http/Concurrent/CurlMultiTransport.php +++ b/src/Http/Concurrent/CurlMultiTransport.php @@ -106,7 +106,7 @@ public function sendMany( if ($scheduled['cancelled']) { $cancelled = true; $stoppedScheduling = true; - $this->cancelOutstanding($multiHandle, $requests, $keys, $nextIndex, $contexts, $results); + $this->cancelOutstanding($multiHandle, $keys, $nextIndex, $contexts, $results); break; } @@ -120,7 +120,6 @@ public function sendMany( $stoppedScheduling = true; $this->failOutstanding( $multiHandle, - $requests, $keys, $nextIndex, $contexts, @@ -142,7 +141,7 @@ public function sendMany( if ($cancellation?->isRequested() === true) { $cancelled = true; $stoppedScheduling = true; - $this->cancelOutstanding($multiHandle, $requests, $keys, $nextIndex, $contexts, $results); + $this->cancelOutstanding($multiHandle, $keys, $nextIndex, $contexts, $results); break; } @@ -168,6 +167,18 @@ public function sendMany( ); } + private static function cancelledResult(bool $started): CommunicationResult + { + return CommunicationResult::failure( + 'HTTP concurrent operation cancelled.', + metadata: [ + 'transport' => 'curl-multi', + 'cancelled' => true, + 'started' => $started, + ], + ); + } + /** * @param array{key:int|string, handle:\CurlHandle, request:HttpRequest, headerCollector:ResponseHeaderCollector, bodyCollector:ResponseBodyCollector} $context */ @@ -179,14 +190,12 @@ private function abortContext(\CurlMultiHandle $multiHandle, array $context): vo } /** - * @param array $requests * @param list $keys * @param array $contexts * @param array $results */ private function cancelOutstanding( \CurlMultiHandle $multiHandle, - array $requests, array $keys, int $nextIndex, array &$contexts, @@ -224,7 +233,11 @@ private function collectCompleted( $failureObserved = false; while (($message = curl_multi_info_read($multiHandle)) !== false) { - $handle = $message['handle']; + $handle = $message['handle'] ?? null; + if (!$handle instanceof \CurlHandle) { + continue; + } + $handleId = spl_object_id($handle); $context = $contexts[$handleId] ?? null; if ($context === null) { @@ -246,18 +259,6 @@ private function collectCompleted( return ['count' => $count, 'failure_observed' => $failureObserved]; } - private static function cancelledResult(bool $started): CommunicationResult - { - return CommunicationResult::failure( - 'HTTP concurrent operation cancelled.', - metadata: [ - 'transport' => 'curl-multi', - 'cancelled' => true, - 'started' => $started, - ], - ); - } - private function dispatchRequestResultEvent(HttpRequest $request, CommunicationResult $result): void { $this->events->dispatch($result->successful ? 'http.request.finish' : 'http.request.failed', [ @@ -282,18 +283,16 @@ private function executeMulti(\CurlMultiHandle $multiHandle): array ]; } - return ['error' => null, 'running' => $running]; + return ['error' => null, 'running' => (int) $running]; } /** - * @param array $requests * @param list $keys * @param array $contexts * @param array $results */ private function failOutstanding( \CurlMultiHandle $multiHandle, - array $requests, array $keys, int $nextIndex, array &$contexts, From 9726f940c3c7ff96067e42ac3aed6e9441362b0c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 17:20:01 +0600 Subject: [PATCH 25/51] fix: finalize rolling scheduler gate cleanup --- src/Http/Concurrent/CurlMultiTransport.php | 62 +++++++++++----------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/Http/Concurrent/CurlMultiTransport.php b/src/Http/Concurrent/CurlMultiTransport.php index 7de19a5..335ad63 100644 --- a/src/Http/Concurrent/CurlMultiTransport.php +++ b/src/Http/Concurrent/CurlMultiTransport.php @@ -283,7 +283,7 @@ private function executeMulti(\CurlMultiHandle $multiHandle): array ]; } - return ['error' => null, 'running' => (int) $running]; + return ['error' => null, 'running' => is_int($running) ? $running : 0]; } /** @@ -324,36 +324,6 @@ private function failOutstanding( } } - /** - * @param array $requests - * @param array $results - */ - 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 array{key:int|string, handle:\CurlHandle, request:HttpRequest, headerCollector:ResponseHeaderCollector, bodyCollector:ResponseBodyCollector} $context */ @@ -400,6 +370,36 @@ private function finalizeContext(array $context): CommunicationResult return $result; } + /** + * @param array $requests + * @param array $results + */ + 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 From 6f11be8d7e9fad12c05d13d92708ac81dd9828c4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 17:21:51 +0600 Subject: [PATCH 26/51] test: prove rolling HTTP multi scheduling and cancellation --- tests/Fixtures/concurrent-http-server.php | 131 ++++++++++++++ tests/HttpConcurrentPoolTest.php | 205 ++++++++++++++++++++++ tests/HttpStreamingTest.php | 13 ++ 3 files changed, 349 insertions(+) create mode 100644 tests/Fixtures/concurrent-http-server.php diff --git a/tests/Fixtures/concurrent-http-server.php b/tests/Fixtures/concurrent-http-server.php new file mode 100644 index 0000000..aad9f3c --- /dev/null +++ b/tests/Fixtures/concurrent-http-server.php @@ -0,0 +1,131 @@ + $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)])); + exit(1); +} + +stream_set_blocking($server, false); +$address = stream_socket_get_name($server, false); +if (!is_string($address) || !str_contains($address, ':')) { + fclose($server); + exit(1); +} + +$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/HttpConcurrentPoolTest.php b/tests/HttpConcurrentPoolTest.php index ea44614..50aa654 100644 --- a/tests/HttpConcurrentPoolTest.php +++ b/tests/HttpConcurrentPoolTest.php @@ -10,6 +10,159 @@ 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']), @@ -109,3 +262,55 @@ 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); +}); From 79db70c184243fe1547bb207d3bfbe81f0788afe Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 17:23:49 +0600 Subject: [PATCH 27/51] fix: clean concurrent HTTP fixture quality gates --- tests/Fixtures/concurrent-http-server.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/Fixtures/concurrent-http-server.php b/tests/Fixtures/concurrent-http-server.php index aad9f3c..9ebb8e1 100644 --- a/tests/Fixtures/concurrent-http-server.php +++ b/tests/Fixtures/concurrent-http-server.php @@ -8,7 +8,7 @@ $decoded = json_decode((string) file_get_contents($scenarioPath), true); if (!is_array($decoded)) { - exit(1); + throw new RuntimeException('Concurrent HTTP fixture scenario must decode to an array.'); } $delays = []; @@ -18,17 +18,19 @@ } } -$server = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); +$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)])); - exit(1); + + 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); - exit(1); + + throw new RuntimeException('Unable to resolve concurrent HTTP fixture server address.'); } $port = (int) substr(strrchr($address, ':'), 1); @@ -55,11 +57,11 @@ $write = []; $except = []; - @stream_select($read, $write, $except, 0, 10_000); + stream_select($read, $write, $except, 0, 10_000); foreach ($read as $stream) { if ($stream === $server) { - while (($client = @stream_socket_accept($server, 0)) !== false) { + while (($client = stream_socket_accept($server, 0)) !== false) { stream_set_blocking($client, false); $clients[(int) $client] = [ 'stream' => $client, @@ -113,7 +115,7 @@ . 'Connection: close' . "\r\n\r\n" . $body; - @fwrite($client['stream'], $response); + fwrite($client['stream'], $response); fclose($client['stream']); unset($clients[$id]); $completed++; From 0d19f25b5f651de04b95715dcc0cd7ee36c2f1ea Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 17:26:12 +0600 Subject: [PATCH 28/51] docs: mark green Batch 6 rolling scheduler --- ...1-foundation-integration-hardening-plan.md | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md index ffe294d..96b1a76 100644 --- a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md +++ b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md @@ -10,9 +10,9 @@ Baseline: - implementation baseline branch: main - released baseline: 2.0.0 - baseline commit: 86d0e9dde8124ddeacea8ba7f81911af584b879b -- current implementation head through Batch 3: 908dc992e1b9b430fb084b2c283c5217fcc9f8b9 +- current implementation head through Batch 6: 79db70c184243fe1547bb207d3bfbe81f0788afe - primary consumer: Foundation 3 runtime plan point 26.9 -- plan state: **ACTIVE — BATCHES 1–5 GREEN / BATCH 6 NEXT** +- plan state: **ACTIVE — BATCHES 1–6 GREEN / BATCH 7 NEXT** Batch progress: @@ -21,7 +21,7 @@ Batch progress: - [x] Batch 3 — email runtime and sendmail process hardening - [x] Batch 4 — native protocol composition builders - [x] Batch 5 — webhook replay acceptance -- [ ] Batch 6 — HTTP rolling multi scheduler +- [x] Batch 6 — HTTP rolling multi scheduler - [ ] Batch 7 — gRPC generated adapter determinism - [ ] Batch 8 — observability and data minimization - [ ] Batch 9 — optional cold graphs, docs and benchmarks @@ -366,9 +366,9 @@ Do not create a general task framework. - [x] Allow mailbox watch loops to consume the same cancellation abstraction while retaining callable compatibility where practical. - [ ] Allow generated/native gRPC streaming loops to check cancellation between messages/writes/reads where the native API permits. - [x] Allow the inbound gRPC accepted-call bridge to stop before accepting the next exchange. -- [ ] Allow CurlMultiTransport to stop scheduling and terminate/close active work safely when host cancellation is requested, if libcurl semantics permit deterministic cleanup. +- [x] Allow CurlMultiTransport to stop scheduling and terminate/close active work safely when host cancellation is requested, if libcurl semantics permit deterministic cleanup. - [ ] Add deterministic fake-clock/fake-sleeper/cancellation tests. -- [ ] Verify cancellation never skips required resource cleanup. +- [x] Verify cancellation never skips required resource cleanup. ### Foundation handoff @@ -609,17 +609,17 @@ Improve throughput without threads, forks or a new async framework. ### Tasks -- [ ] Replace array_chunk batch scheduling with a rolling cURL multi window up to maxConcurrency. -- [ ] As soon as one handle completes, schedule the next pending request. -- [ ] Preserve result ordering by original keys. -- [ ] Preserve bounded concurrency. -- [ ] Preserve cleanup on every failure/listener/cancellation path. -- [ ] Preserve current truthful stopSchedulingOnFailure semantics. -- [ ] When a failure is observed and stop-scheduling is enabled, stop adding new requests immediately. -- [ ] Do not claim active-request fail-fast cancellation unless it is actually implemented. -- [ ] If cancellation is supplied, close/remove active handles safely and return deterministic cancelled results/metadata. -- [ ] Keep manual redirect security behavior; do not re-enable unsafe automatic redirect handling in CurlMultiTransport. -- [ ] Move pool durations to monotonic Clock. +- [x] Replace array_chunk batch scheduling with a rolling cURL multi window up to maxConcurrency. +- [x] As soon as one handle completes, schedule the next pending request. +- [x] Preserve result ordering by original keys. +- [x] Preserve bounded concurrency. +- [x] Preserve cleanup on every failure/listener/cancellation path. +- [x] Preserve current truthful stopSchedulingOnFailure semantics. +- [x] When a failure is observed and stop-scheduling is enabled, stop adding new requests immediately. +- [x] Do not claim active-request fail-fast cancellation unless it is actually implemented. +- [x] If cancellation is supplied, close/remove active handles safely and return deterministic cancelled results/metadata. +- [x] Keep manual redirect security behavior; do not re-enable unsafe automatic redirect handling in CurlMultiTransport. +- [x] Move pool durations to monotonic Clock. - [ ] Benchmark chunked 2.0 behavior versus rolling-window 2.1 behavior with mixed fast/slow fake/local endpoints. - [ ] Track allocation/handle cleanup under repeated runs. @@ -756,7 +756,7 @@ Foundation owns bridge attribution. - [ ] Update architecture docs with ownership/lifetime/cancellation boundaries. - [x] Update events docs: injected dispatcher primary; static bus compatibility-only. -- [ ] Update HTTP concurrency docs for rolling scheduling and cancellation semantics. +- [x] Update HTTP concurrency docs for rolling scheduling and cancellation semantics. - [ ] Update webhook replay docs with atomic/fail-closed requirements. - [x] Update gRPC inbound docs for the host-runtime bridge and wire-error data minimization. - [ ] Update gRPC generated/native docs for deterministic adapter behavior. @@ -923,14 +923,14 @@ This is a planning map, not a requirement to modify every file. - contention/error tests; - preserve provider neutrality. -### Batch 6 — HTTP rolling multi scheduler ⏳ +### Batch 6 — HTTP rolling multi scheduler ✅ - rolling window; - stop-scheduling behavior; - cancellation/cleanup; - throughput benchmark. -### Batch 7 — gRPC generated adapter determinism +### Batch 7 — gRPC generated adapter determinism ⏳ - remove TypeError execution probing; - deterministic call-shape resolution; @@ -1010,7 +1010,7 @@ TalkingBytes 2.1 is complete only when: - [ ] native inbound/outbound email APIs remain authoritative; - [ ] sendmail timeout/cancellation/process-tree cleanup is deterministic; - [ ] posix use is optional and pcntl is not required/default; -- [ ] HTTP multi scheduling uses a rolling concurrency window or the optimization is explicitly rejected with benchmark evidence; +- [x] HTTP multi scheduling uses a rolling concurrency window or the optimization is explicitly rejected with benchmark evidence; - [ ] Foundation protocol-composition duplication has corresponding native TalkingBytes APIs ready for consumption; - [ ] secret/PII sentinel tests pass across protocol observability; - [ ] unrelated optional capabilities remain cold until selected; From d9c9ef8f67cfe044abba4c50016bb6896211a30a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 17:32:03 +0600 Subject: [PATCH 29/51] feat: make generated grpc stream adaptation deterministic --- docs/grpc/native.rst | 14 +- docs/grpc/streaming.rst | 13 ++ src/Grpc/GrpcClient.php | 3 +- src/Grpc/GrpcClientFactory.php | 7 +- src/Grpc/Native/GeneratedStubGrpcInvoker.php | 200 ++++++++++++++++--- tests/GrpcGeneratedStubInvokerTest.php | 193 ++++++++++++++++++ 6 files changed, 396 insertions(+), 34 deletions(-) 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/src/Grpc/GrpcClient.php b/src/Grpc/GrpcClient.php index 4b9af39..b828297 100644 --- a/src/Grpc/GrpcClient.php +++ b/src/Grpc/GrpcClient.php @@ -58,8 +58,9 @@ public static function usingGeneratedStub( object $stubClient, array $methodMap = [], ?EventDispatcher $events = null, + ?CancellationSignal $cancellation = null, ): self { - $invoker = new GeneratedStubGrpcInvoker($stubClient, $methodMap); + $invoker = new GeneratedStubGrpcInvoker($stubClient, $methodMap, $cancellation); return self::usingNativeStreaming($invoker, $invoker, $events); } diff --git a/src/Grpc/GrpcClientFactory.php b/src/Grpc/GrpcClientFactory.php index e732e34..cf5ac4c 100644 --- a/src/Grpc/GrpcClientFactory.php +++ b/src/Grpc/GrpcClientFactory.php @@ -42,7 +42,12 @@ public function usingGeneratedStub( array $config = [], ): GrpcClient { return $this->applyResolvedConfig( - GrpcClient::usingGeneratedStub($stubClient, $methodMap, $this->events), + GrpcClient::usingGeneratedStub( + $stubClient, + $methodMap, + $this->events, + $this->cancellation, + ), $config, ); } diff --git a/src/Grpc/Native/GeneratedStubGrpcInvoker.php b/src/Grpc/Native/GeneratedStubGrpcInvoker.php index 2ceff7c..83f835c 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/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'); +}); From d61b0286a8a8661d4896d81eb3cfc0c553758440 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 17:34:08 +0600 Subject: [PATCH 30/51] fix: align grpc method-map validation types --- src/Grpc/Native/GeneratedStubGrpcInvoker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Grpc/Native/GeneratedStubGrpcInvoker.php b/src/Grpc/Native/GeneratedStubGrpcInvoker.php index 83f835c..8bf38b0 100644 --- a/src/Grpc/Native/GeneratedStubGrpcInvoker.php +++ b/src/Grpc/Native/GeneratedStubGrpcInvoker.php @@ -422,7 +422,7 @@ private function invokeStubMethod(string $methodName, array $args): mixed } /** - * @param array $methodMap + * @param array $methodMap * @return array */ private function normalizeMethodMap(ReflectionObject $reflection, array $methodMap): array From 9695a6a0e5e70465c83446bd5d426c7f4791c44f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 17:36:10 +0600 Subject: [PATCH 31/51] docs: mark green Batch 7 grpc adapter work --- ...1-foundation-integration-hardening-plan.md | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md index 96b1a76..26233f5 100644 --- a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md +++ b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md @@ -10,9 +10,9 @@ Baseline: - implementation baseline branch: main - released baseline: 2.0.0 - baseline commit: 86d0e9dde8124ddeacea8ba7f81911af584b879b -- current implementation head through Batch 6: 79db70c184243fe1547bb207d3bfbe81f0788afe +- current implementation head through Batch 7: d61b0286a8a8661d4896d81eb3cfc0c553758440 - primary consumer: Foundation 3 runtime plan point 26.9 -- plan state: **ACTIVE — BATCHES 1–6 GREEN / BATCH 7 NEXT** +- plan state: **ACTIVE — BATCHES 1–7 GREEN / BATCH 8 NEXT** Batch progress: @@ -22,7 +22,7 @@ Batch progress: - [x] Batch 4 — native protocol composition builders - [x] Batch 5 — webhook replay acceptance - [x] Batch 6 — HTTP rolling multi scheduler -- [ ] Batch 7 — gRPC generated adapter determinism +- [x] Batch 7 — gRPC generated adapter determinism - [ ] Batch 8 — observability and data minimization - [ ] Batch 9 — optional cold graphs, docs and benchmarks - [ ] Batch 10 — exact-head release gate @@ -364,7 +364,7 @@ Do not create a general task framework. - [x] Allow HTTP retry and gRPC retry to stop before sleeping/retrying when cancelled. - [ ] Allow WebhookSender retry to stop cooperatively. - [x] Allow mailbox watch loops to consume the same cancellation abstraction while retaining callable compatibility where practical. -- [ ] Allow generated/native gRPC streaming loops to check cancellation between messages/writes/reads where the native API permits. +- [x] Allow generated/native gRPC streaming loops to check cancellation between messages/writes/reads where the native API permits. - [x] Allow the inbound gRPC accepted-call bridge to stop before accepting the next exchange. - [x] Allow CurlMultiTransport to stop scheduling and terminate/close active work safely when host cancellation is requested, if libcurl semantics permit deterministic cleanup. - [ ] Add deterministic fake-clock/fake-sleeper/cancellation tests. @@ -633,17 +633,17 @@ Do not add pcntl_fork, pthreads, parallel, ReactPHP or Amp merely for this sched ### Tasks -- [ ] Remove exception-driven TypeError probing for generated streaming call shape. -- [ ] Resolve the supported generated-stub call shape before executing the real call. -- [ ] Prefer explicit adapter metadata/callable strategy or bounded reflection cached at adapter construction. -- [ ] Never retry an invocation merely because a TypeError was thrown from inside the invoked method. -- [ ] Validate method maps early. -- [ ] Keep generated/native package capability checks cold. -- [ ] Add cancellation checks between outbound stream writes and inbound reads where possible. -- [ ] Preserve incremental streaming; never accumulate full streams. -- [ ] Ensure callback exceptions close/finalize native call resources deterministically. -- [ ] Add tests proving no duplicate side effect occurs during call-shape resolution. -- [ ] Add tests for cancellation, callback failure and final status/trailer handling. +- [x] Remove exception-driven TypeError probing for generated streaming call shape. +- [x] Resolve the supported generated-stub call shape before executing the real call. +- [x] Prefer explicit adapter metadata/callable strategy or bounded reflection cached at adapter construction. +- [x] Never retry an invocation merely because a TypeError was thrown from inside the invoked method. +- [x] Validate method maps early. +- [x] Keep generated/native package capability checks cold. +- [x] Add cancellation checks between outbound stream writes and inbound reads where possible. +- [x] Preserve incremental streaming; never accumulate full streams. +- [x] Ensure callback exceptions close/finalize native call resources deterministically. +- [x] Add tests proving no duplicate side effect occurs during call-shape resolution. +- [x] Add tests for cancellation, callback failure and final status/trailer handling. --- @@ -759,7 +759,7 @@ Foundation owns bridge attribution. - [x] Update HTTP concurrency docs for rolling scheduling and cancellation semantics. - [ ] Update webhook replay docs with atomic/fail-closed requirements. - [x] Update gRPC inbound docs for the host-runtime bridge and wire-error data minimization. -- [ ] Update gRPC generated/native docs for deterministic adapter behavior. +- [x] Update gRPC generated/native docs for deterministic adapter behavior. - [x] Update email docs for persistent-worker connection ownership. - [x] Update sendmail docs for timeout/cancellation/POSIX optional behavior. - [ ] Update security docs with secret/PII redaction guarantees. @@ -930,7 +930,7 @@ This is a planning map, not a requirement to modify every file. - cancellation/cleanup; - throughput benchmark. -### Batch 7 — gRPC generated adapter determinism ⏳ +### Batch 7 — gRPC generated adapter determinism ✅ - remove TypeError execution probing; - deterministic call-shape resolution; From e16778eef9c9f430d179116b75021876ea439cc0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 18:06:51 +0600 Subject: [PATCH 32/51] feat: harden protocol observability redaction --- docs/email/outbound.rst | 14 +- docs/security.rst | 24 +++ src/Core/Support/ObservabilitySanitizer.php | 76 ++++++++++ src/Email/Emailer.php | 4 +- src/Email/Mailbox/MailboxCommandRedactor.php | 14 +- src/Email/Receiver/SpoolEmailReceiver.php | 14 +- src/Email/Transport/LoggingEmailTransport.php | 11 +- src/Grpc/GrpcClient.php | 3 +- src/Grpc/Sender/GrpcTransport.php | 3 +- src/Http/Concurrent/CurlMultiTransport.php | 3 +- src/Http/Support/HttpRedactor.php | 2 + src/Http/Transport/CurlTransport.php | 3 +- src/Webhook/WebhookSender.php | 7 +- tests/MailboxCommandRedactorTest.php | 14 +- tests/ObservabilitySanitizationTest.php | 143 ++++++++++++++++++ 15 files changed, 294 insertions(+), 41 deletions(-) create mode 100644 src/Core/Support/ObservabilitySanitizer.php create mode 100644 tests/ObservabilitySanitizationTest.php diff --git a/docs/email/outbound.rst b/docs/email/outbound.rst index 2b97dd9..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 @@ -132,3 +132,15 @@ 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/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/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/Email/Emailer.php b/src/Email/Emailer.php index dc631a1..81f6ce2 100644 --- a/src/Email/Emailer.php +++ b/src/Email/Emailer.php @@ -10,6 +10,7 @@ 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; @@ -110,7 +111,6 @@ 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), @@ -121,7 +121,7 @@ public function send(EmailMessage $message): CommunicationResult $this->events->dispatch('email.send.finish', [ 'successful' => $result->successful, - 'error' => $result->error, + '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'] 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/Receiver/SpoolEmailReceiver.php b/src/Email/Receiver/SpoolEmailReceiver.php index f45d088..04ed490 100644 --- a/src/Email/Receiver/SpoolEmailReceiver.php +++ b/src/Email/Receiver/SpoolEmailReceiver.php @@ -269,7 +269,6 @@ private function readNext(bool $consume): ?ParsedEmail $startedAt = $this->clock->monotonic(); $this->events->dispatch('email.receive.start', [ 'source' => 'spool', - 'path' => $sourceFile, 'consume' => $consume, ]); @@ -281,8 +280,7 @@ private function readNext(bool $consume): ?ParsedEmail $this->events->dispatch('email.receive.finish', [ 'source' => 'spool', 'successful' => false, - 'path' => $processingFile, - 'error' => 'Unable to read spool file.', + 'failure_category' => 'read_failure', 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000), ]); @@ -294,14 +292,14 @@ private function readNext(bool $consume): ?ParsedEmail $this->markFailed($processingFile, $exception->getMessage()); $this->events->dispatch('email.parse.failed', [ 'source' => 'spool', - 'path' => $processingFile, - 'error' => $exception->getMessage(), + 'failure_category' => 'parse_failure', + 'exception_class' => $exception::class, ]); $this->events->dispatch('email.receive.finish', [ 'source' => 'spool', 'successful' => false, - 'path' => $processingFile, - 'error' => $exception->getMessage(), + 'failure_category' => 'parse_failure', + 'exception_class' => $exception::class, 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000), ]); @@ -315,8 +313,6 @@ private function readNext(bool $consume): ?ParsedEmail $this->events->dispatch('email.receive.finish', [ 'source' => 'spool', 'successful' => true, - 'path' => $processingFile, - 'subject' => $parsed->subject, 'duration_ms' => (int) round(($this->clock->monotonic() - $startedAt) * 1000), ]); diff --git a/src/Email/Transport/LoggingEmailTransport.php b/src/Email/Transport/LoggingEmailTransport.php index 41d6dbc..a3e43c9 100644 --- a/src/Email/Transport/LoggingEmailTransport.php +++ b/src/Email/Transport/LoggingEmailTransport.php @@ -5,6 +5,7 @@ namespace Infocyph\TalkingBytes\Email\Transport; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer; use Infocyph\TalkingBytes\Email\EmailMessage; use Throwable; @@ -24,7 +25,6 @@ public function send(EmailMessage $message): CommunicationResult 'to_count' => 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/Grpc/GrpcClient.php b/src/Grpc/GrpcClient.php index b828297..a63e0e3 100644 --- a/src/Grpc/GrpcClient.php +++ b/src/Grpc/GrpcClient.php @@ -9,6 +9,7 @@ use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer; use Infocyph\TalkingBytes\Grpc\Contract\GrpcMiddleware; use Infocyph\TalkingBytes\Grpc\Middleware\RetryMiddleware; use Infocyph\TalkingBytes\Grpc\Native\GeneratedStubGrpcInvoker; @@ -252,7 +253,7 @@ private function runStream(string $streamType, string $method, callable $execute 'type' => $streamType, 'method' => $method, 'duration_ms' => $durationMs, - 'error' => $exception->getMessage(), + ...ObservabilitySanitizer::throwableContext($exception), ]); $error = new GrpcCallError( diff --git a/src/Grpc/Sender/GrpcTransport.php b/src/Grpc/Sender/GrpcTransport.php index 1b01ac6..f019757 100644 --- a/src/Grpc/Sender/GrpcTransport.php +++ b/src/Grpc/Sender/GrpcTransport.php @@ -9,6 +9,7 @@ use Infocyph\TalkingBytes\Core\Event\EventDispatcher; use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer; use Infocyph\TalkingBytes\Grpc\GrpcStatus; use Throwable; @@ -48,7 +49,7 @@ public function send(GrpcRequest $grpcRequest): CommunicationResult 'transport' => 'grpc', 'method' => $grpcRequest->method, 'duration_ms' => $durationMs, - 'error' => $exception->getMessage(), + ...ObservabilitySanitizer::throwableContext($exception), ]); $error = new GrpcCallError( diff --git a/src/Http/Concurrent/CurlMultiTransport.php b/src/Http/Concurrent/CurlMultiTransport.php index 335ad63..98f80c6 100644 --- a/src/Http/Concurrent/CurlMultiTransport.php +++ b/src/Http/Concurrent/CurlMultiTransport.php @@ -10,6 +10,7 @@ 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; @@ -265,7 +266,7 @@ 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', ]); } 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..b680ebe 100644 --- a/src/Http/Transport/CurlTransport.php +++ b/src/Http/Transport/CurlTransport.php @@ -8,6 +8,7 @@ use Infocyph\TalkingBytes\Core\Event\EventDispatcher; use Infocyph\TalkingBytes\Core\Event\NullEventDispatcher; use Infocyph\TalkingBytes\Core\Result\CommunicationResult; +use Infocyph\TalkingBytes\Core\Support\ObservabilitySanitizer; use Infocyph\TalkingBytes\Http\Contract\HttpTransport; use Infocyph\TalkingBytes\Http\HttpRequest; use Infocyph\TalkingBytes\Http\Internal\CurlHandleConfigurator; @@ -207,7 +208,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/WebhookSender.php b/src/Webhook/WebhookSender.php index 76afcbe..26a0e73 100644 --- a/src/Webhook/WebhookSender.php +++ b/src/Webhook/WebhookSender.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\Core\Support\ObservabilitySanitizer; use Infocyph\TalkingBytes\Core\Support\Sleeper; use Infocyph\TalkingBytes\Http\HttpClient; use Infocyph\TalkingBytes\Http\HttpRequest; @@ -87,7 +88,7 @@ public function send(WebhookMessage $webhook): WebhookDelivery 'url' => $redactedUrl, 'attempt' => 1, ]); - $startedAt = microtime(true); + $startedAt = $this->clock->monotonic(); $attempt = 1; $retryPolicy = $this->retryProfile?->toHttpRetryPolicy(); @@ -120,7 +121,7 @@ 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; @@ -139,7 +140,7 @@ public function send(WebhookMessage $webhook): WebhookDelivery 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, ], ); 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/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]'); +}); From 45c7dd11d1c77c3e4ff1b68ab4a1999ed5f6761f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 18:10:11 +0600 Subject: [PATCH 33/51] fix: align observability tests with sanitized contract --- src/Webhook/WebhookSender.php | 4 +++- tests/EmailOutboundExtensionsTest.php | 4 +++- tests/MailboxImapTest.php | 5 +++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Webhook/WebhookSender.php b/src/Webhook/WebhookSender.php index 26a0e73..99bb3db 100644 --- a/src/Webhook/WebhookSender.php +++ b/src/Webhook/WebhookSender.php @@ -152,7 +152,9 @@ 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, ], 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/MailboxImapTest.php b/tests/MailboxImapTest.php index eaeda8d..68762cc 100644 --- a/tests/MailboxImapTest.php +++ b/tests/MailboxImapTest.php @@ -727,11 +727,12 @@ static function (string $event) use (&$events, &$done): void { 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(); }); From 9ffc337e01d9c9248650a5daeb0b8427bb643b94 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 18:12:52 +0600 Subject: [PATCH 34/51] docs: mark green Batch 8 observability hardening --- ...1-foundation-integration-hardening-plan.md | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md index 26233f5..ea6b313 100644 --- a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md +++ b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md @@ -10,9 +10,9 @@ Baseline: - implementation baseline branch: main - released baseline: 2.0.0 - baseline commit: 86d0e9dde8124ddeacea8ba7f81911af584b879b -- current implementation head through Batch 7: d61b0286a8a8661d4896d81eb3cfc0c553758440 +- current implementation head through Batch 8: 45c7dd11d1c77c3e4ff1b68ab4a1999ed5f6761f - primary consumer: Foundation 3 runtime plan point 26.9 -- plan state: **ACTIVE — BATCHES 1–7 GREEN / BATCH 8 NEXT** +- plan state: **ACTIVE — BATCHES 1–8 GREEN / BATCH 9 NEXT** Batch progress: @@ -23,7 +23,7 @@ Batch progress: - [x] Batch 5 — webhook replay acceptance - [x] Batch 6 — HTTP rolling multi scheduler - [x] Batch 7 — gRPC generated adapter determinism -- [ ] Batch 8 — observability and data minimization +- [x] Batch 8 — observability and data minimization - [ ] Batch 9 — optional cold graphs, docs and benchmarks - [ ] Batch 10 — exact-head release gate @@ -655,21 +655,21 @@ Default events/log context must not expose secrets or unnecessary payload/PII. ### Tasks -- [ ] Never emit raw Authorization credentials. -- [ ] Never emit raw bearer/API tokens. -- [ ] Never emit cookie values. -- [ ] Never emit proxy credentials. -- [ ] Never emit webhook secrets/signatures/bodies. -- [ ] Never emit SMTP/mailbox passwords or raw auth commands. -- [ ] Avoid raw gRPC metadata values unless explicitly classified safe. -- [ ] Do not copy raw exception messages blindly into protocol events. -- [ ] Prefer stable failure category, exception class where locally appropriate, protocol status/code and bounded sanitized diagnostics. -- [ ] Remove exception class from remote gRPC response metadata. -- [ ] Review SMTP transcript capture and document it as explicit diagnostic data with clear redaction guarantees. -- [ ] Remove or gate spool absolute paths and email subjects from default events when they are not required. -- [ ] Keep caller-facing CommunicationResult diagnostics useful; local observability may intentionally be stricter. -- [ ] Add sentinel-secret and sentinel-PII tests across HTTP, webhook, gRPC, email and mailbox event payloads. -- [ ] Keep hot-path redaction overhead bounded. +- [x] Never emit raw Authorization credentials. +- [x] Never emit raw bearer/API tokens. +- [x] Never emit cookie values. +- [x] Never emit proxy credentials. +- [x] Never emit webhook secrets/signatures/bodies. +- [x] Never emit SMTP/mailbox passwords or raw auth commands. +- [x] Avoid raw gRPC metadata values unless explicitly classified safe. +- [x] Do not copy raw exception messages blindly into protocol events. +- [x] Prefer stable failure category, exception class where locally appropriate, protocol status/code and bounded sanitized diagnostics. +- [x] Remove exception class from remote gRPC response metadata. +- [x] Review SMTP transcript capture and document it as explicit diagnostic data with clear redaction guarantees. +- [x] Remove or gate spool absolute paths and email subjects from default events when they are not required. +- [x] Keep caller-facing CommunicationResult diagnostics useful; local observability may intentionally be stricter. +- [x] Add sentinel-secret and sentinel-PII tests across HTTP, webhook, gRPC, email and mailbox event payloads. +- [x] Keep hot-path redaction overhead bounded. --- @@ -762,7 +762,7 @@ Foundation owns bridge attribution. - [x] Update gRPC generated/native docs for deterministic adapter behavior. - [x] Update email docs for persistent-worker connection ownership. - [x] Update sendmail docs for timeout/cancellation/POSIX optional behavior. -- [ ] Update security docs with secret/PII redaction guarantees. +- [x] Update security docs with secret/PII redaction guarantees. - [ ] Update performance docs with persistent-runtime guidance. - [x] Update testing docs with isolation, fake cancellation and fake inbound-runtime examples. - [ ] Update release checklist with static-state, monotonic-time, cancellation, optional-cold and secret-sentinel gates. @@ -936,13 +936,13 @@ This is a planning map, not a requirement to modify every file. - deterministic call-shape resolution; - streaming cancellation/failure cleanup. -### Batch 8 — Observability and data minimization +### Batch 8 — Observability and data minimization ✅ - raw failure audit; - secret/PII sentinels; - transcript/path/subject policy. -### Batch 9 — Optional cold graphs, docs and benchmarks +### Batch 9 — Optional cold graphs, docs and benchmarks ⏳ - extension/package absence matrix; - native benchmark evidence; From b20b72adf7313398ad821093988258ce0ffcc277 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:04:06 +0600 Subject: [PATCH 35/51] test: enforce optional capability coldness --- .github/workflows/security-standards.yml | 33 ++++++ docs/architecture.rst | 47 +++++++++ docs/extensions.rst | 31 +++++- docs/release-checklist.rst | 36 +++++-- tests/OptionalCapabilityColdnessTest.php | 125 +++++++++++++++++++++++ 5 files changed, 264 insertions(+), 8 deletions(-) create mode 100644 tests/OptionalCapabilityColdnessTest.php diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 1bbf287..1c43c21 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -20,6 +20,39 @@ jobs: actions: read contents: read + 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, :pcntl, :sodium" + coverage: none + + - name: Verify optional extensions are absent + run: | + php -r ' + foreach (["grpc", "imap", "posix", "pcntl", "sodium"] 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 services: diff --git a/docs/architecture.rst b/docs/architecture.rst index edeb5e2..da2ad14 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -30,6 +30,51 @@ 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 the optional extensions disabled. + Module boundaries ----------------- @@ -48,3 +93,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/extensions.rst b/docs/extensions.rst index 4a97887..4675c91 100644 --- a/docs/extensions.rst +++ b/docs/extensions.rst @@ -19,8 +19,37 @@ Suggested extensions/packages - ``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 ``ext-grpc``, +``ext-imap``, ``ext-posix``, ``ext-pcntl``, and ``ext-sodium`` +disabled and exercises the unrelated protocol graphs. Fallback behavior ----------------- -Some parser/mailbox/process helpers degrade gracefully when optional extensions are unavailable. Sendmail process-group isolation is opportunistic; direct-child termination remains the portable fallback when POSIX functions are absent or cannot isolate the child. +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/release-checklist.rst b/docs/release-checklist.rst index cd577e9..87b0602 100644 --- a/docs/release-checklist.rst +++ b/docs/release-checklist.rst @@ -15,23 +15,41 @@ 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 gRPC, IMAP, POSIX, + PCNTL, and Sodium disabled +- 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 +58,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/tests/OptionalCapabilityColdnessTest.php b/tests/OptionalCapabilityColdnessTest.php new file mode 100644 index 0000000..743b731 --- /dev/null +++ b/tests/OptionalCapabilityColdnessTest.php @@ -0,0 +1,125 @@ + 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', 'pcntl', 'sodium'] 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; + } + + expect(extension_loaded('sodium'))->toBeFalse(); + + $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; + } + + expect(extension_loaded('sodium'))->toBeFalse(); + + expect(static fn() => DkimConfig::fromPrivateKeyString( + 'example.test', + 'selector', + base64_encode(random_bytes(32)), + algorithm: DkimAlgorithm::Ed25519Sha256, + ))->toThrow(RuntimeException::class, 'Sodium extension is required for Ed25519 DKIM signing.'); +}); From 4f2fae41e77ffa7d9734f9c2f3f7e829e601b6d3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:08:22 +0600 Subject: [PATCH 36/51] bench: add native runtime and soak evidence --- benchmarks/EmailBench.php | 21 +++++++++++++ benchmarks/GrpcBench.php | 54 ++++++++++++++++++++++++++++++++ benchmarks/HttpBench.php | 58 +++++++++++++++++++++++++++++++++++ benchmarks/WebhookBench.php | 37 +++++++++++++++++++--- docs/performance.rst | 61 ++++++++++++++++++++++++++++++++----- tests/RuntimeSoakTest.php | 56 ++++++++++++++++++++++++++++++++++ 6 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 tests/RuntimeSoakTest.php diff --git a/benchmarks/EmailBench.php b/benchmarks/EmailBench.php index a53dfa8..c38aed2 100644 --- a/benchmarks/EmailBench.php +++ b/benchmarks/EmailBench.php @@ -4,6 +4,7 @@ namespace Infocyph\TalkingBytes\Benchmarks; +use Infocyph\TalkingBytes\Email\Emailer; use Infocyph\TalkingBytes\Email\EmailMessage; use Infocyph\TalkingBytes\Email\Parser\RawEmailParser; use Infocyph\TalkingBytes\Email\System\RawEmailBuilder; @@ -16,8 +17,12 @@ final class EmailBench { private RawEmailBuilder $builder; + private Emailer $fakeEmailer; + private EmailMessage $message; + private Emailer $nullEmailer; + private RawEmailParser $parser; private string $rawMultipartEmail; @@ -26,6 +31,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') @@ -58,6 +65,20 @@ static function (string $chunk): void { ); } + #[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..e8ed7d9 100644 --- a/benchmarks/GrpcBench.php +++ b/benchmarks/GrpcBench.php @@ -4,13 +4,60 @@ 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\Sender\GrpcRequest; +use Infocyph\TalkingBytes\Grpc\Sender\GrpcResponse; +use PhpBench\Attributes\BeforeMethods; use PhpBench\Attributes\Iterations; use PhpBench\Attributes\Revs; +#[BeforeMethods('setUp')] final class GrpcBench { + private GrpcClient $client; + + private GrpcInboundDispatcher $dispatcher; + + private GrpcInboundRequest $inboundRequest; + + private GrpcRequest $request; + + public function setUp(): void + { + $this->client = GrpcClient::using( + static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse( + GrpcStatus::Ok, + $request->message, + ), + ); + $this->dispatcher = new GrpcInboundDispatcher([ + '/orders.v1.OrderService/GetOrder' => static fn(GrpcInboundRequest $request): GrpcInboundResponse => + 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(1000)] + public function benchInboundDispatch(): void + { + $this->dispatcher->handle($this->inboundRequest); + } + #[Iterations(5)] #[Revs(1000)] public function benchRequestAndMetadata(): void @@ -22,4 +69,11 @@ public function benchRequestAndMetadata(): void 1.5, ); } + + #[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..7e6a89b 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,6 +24,13 @@ final class HttpBench { private HttpClient $client; + private HttpClient $cookieClient; + + private HttpClient $fakeClient; + + /** @var array */ + private array $resolvedConfig; + private HttpRequest $request; public function setUp(): void @@ -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..642efe2 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,10 +46,19 @@ 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); $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)] @@ -62,7 +72,24 @@ public function benchParseSignature(): void #[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); + } + + #[Iterations(5)] + #[Revs(1000)] + public function benchVerificationAndReplayClaim(): void + { + $this->verifier->verify($this->payload, $this->signatureHeader, $this->timestamp); + $this->replayCounter++; + $this->replayStore->claim('verify', 'delivery-' . $this->replayCounter, 60); } #[Iterations(5)] 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/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); + } +}); From b947ce3b53dd66afd937d989912bdf972930c6b7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:13:07 +0600 Subject: [PATCH 37/51] fix: finish monotonic transport timing --- src/Email/Emailer.php | 2 +- src/Email/Transport/SmtpTransport.php | 16 ++- src/Grpc/GrpcClient.php | 28 +++-- src/Grpc/GrpcClientFactory.php | 9 +- src/Grpc/Sender/GrpcTransport.php | 12 +- src/Http/HttpClient.php | 11 +- src/Http/HttpClientFactory.php | 3 + src/Http/Transport/CurlTransport.php | 10 +- tests/MonotonicTransportTimingTest.php | 148 +++++++++++++++++++++++++ 9 files changed, 210 insertions(+), 29 deletions(-) create mode 100644 tests/MonotonicTransportTimingTest.php diff --git a/src/Email/Emailer.php b/src/Email/Emailer.php index 81f6ce2..35b4c96 100644 --- a/src/Email/Emailer.php +++ b/src/Email/Emailer.php @@ -91,7 +91,7 @@ public static function usingSendmail( public static function usingSmtp(SmtpConfig $config, ?EventDispatcher $events = null, ?Clock $clock = null): self { - return new self(new SmtpTransport($config), $events, $clock); + return new self(new SmtpTransport($config, clock: $clock), $events, $clock); } public static function usingSpool(SpoolConfig $config, ?EventDispatcher $events = null, ?Clock $clock = null): self 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 a63e0e3..250be53 100644 --- a/src/Grpc/GrpcClient.php +++ b/src/Grpc/GrpcClient.php @@ -9,6 +9,7 @@ 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; @@ -27,6 +28,8 @@ final readonly class GrpcClient { + private Clock $clock; + private EventDispatcher $events; private GrpcPipeline $pipeline; @@ -39,17 +42,19 @@ 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); } /** @@ -60,15 +65,17 @@ public static function usingGeneratedStub( array $methodMap = [], ?EventDispatcher $events = null, ?CancellationSignal $cancellation = null, + ?Clock $clock = null, ): self { $invoker = new GeneratedStubGrpcInvoker($stubClient, $methodMap, $cancellation); - return self::usingNativeStreaming($invoker, $invoker, $events); + 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 { @@ -88,6 +95,7 @@ static function (GrpcRequest $request) use ($invoker): GrpcResponse { ); }, $events, + $clock, ); } @@ -95,11 +103,13 @@ public static function usingNativeStreaming( NativeGrpcInvoker $invoker, NativeGrpcStreamingInvoker $streamingInvoker, ?EventDispatcher $events = null, + ?Clock $clock = null, ): self { return new self( - self::usingNative($invoker, $events)->transport, + self::usingNative($invoker, $events, $clock)->transport, streamingInvoker: $streamingInvoker, events: $events, + clock: $clock, ); } @@ -172,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); } /** @@ -180,7 +190,7 @@ 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, ?CancellationSignal $cancellation = null): self @@ -237,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, @@ -247,7 +257,7 @@ 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, @@ -281,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 index cf5ac4c..9bc8266 100644 --- a/src/Grpc/GrpcClientFactory.php +++ b/src/Grpc/GrpcClientFactory.php @@ -6,6 +6,7 @@ use Infocyph\TalkingBytes\Core\Event\EventDispatcher; use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; use Infocyph\TalkingBytes\Grpc\Native\NativeGrpcInvoker; use Infocyph\TalkingBytes\Grpc\Native\NativeGrpcStreamingInvoker; use Infocyph\TalkingBytes\Grpc\Retry\GrpcRetryPolicy; @@ -18,6 +19,7 @@ public function __construct( private ?EventDispatcher $events = null, private ?CancellationSignal $cancellation = null, + private ?Clock $clock = null, ) {} /** @@ -27,7 +29,7 @@ public function __construct( public function using(callable $caller, array $config = []): GrpcClient { return $this->applyResolvedConfig( - GrpcClient::using($caller, $this->events), + GrpcClient::using($caller, $this->events, $this->clock), $config, ); } @@ -47,6 +49,7 @@ public function usingGeneratedStub( $methodMap, $this->events, $this->cancellation, + $this->clock, ), $config, ); @@ -61,8 +64,8 @@ public function usingNative( array $config = [], ): GrpcClient { $client = $streamingInvoker instanceof NativeGrpcStreamingInvoker - ? GrpcClient::usingNativeStreaming($invoker, $streamingInvoker, $this->events) - : GrpcClient::usingNative($invoker, $this->events); + ? GrpcClient::usingNativeStreaming($invoker, $streamingInvoker, $this->events, $this->clock) + : GrpcClient::usingNative($invoker, $this->events, $this->clock); return $this->applyResolvedConfig($client, $config); } diff --git a/src/Grpc/Sender/GrpcTransport.php b/src/Grpc/Sender/GrpcTransport.php index f019757..e4e26f7 100644 --- a/src/Grpc/Sender/GrpcTransport.php +++ b/src/Grpc/Sender/GrpcTransport.php @@ -9,6 +9,7 @@ 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\Grpc\GrpcStatus; use Throwable; @@ -20,20 +21,23 @@ */ private Closure $caller; + private Clock $clock; + private EventDispatcher $events; /** * @param callable(GrpcRequest): GrpcResponse $caller */ - public function __construct(callable $caller, ?EventDispatcher $events = null) + public function __construct(callable $caller, ?EventDispatcher $events = null, ?Clock $clock = null) { $this->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, @@ -44,7 +48,7 @@ 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, @@ -76,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/Http/HttpClient.php b/src/Http/HttpClient.php index 65d3ad9..8f1092e 100644 --- a/src/Http/HttpClient.php +++ b/src/Http/HttpClient.php @@ -13,6 +13,7 @@ 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; @@ -54,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 @@ -68,9 +69,10 @@ public static function fromConfig( HttpClientConfig $config, ?EventDispatcher $events = null, ?HttpTransport $transport = null, + ?Clock $clock = null, ): self { return new self( - transport: $transport ?? new CurlTransport($events), + transport: $transport ?? new CurlTransport($events, $clock), defaultOptions: new CurlOptions( timeoutSeconds: $config->timeoutSeconds, connectTimeoutSeconds: $config->connectTimeoutSeconds, @@ -98,8 +100,9 @@ public static function fromResolvedConfig( ?EventDispatcher $events = null, ?CancellationSignal $cancellation = null, ?HttpTransport $transport = null, + ?Clock $clock = null, ): self { - return new HttpClientFactory($events, $cancellation)->fromArray($config, $transport); + return new HttpClientFactory($events, $cancellation, $clock)->fromArray($config, $transport); } public static function multi( diff --git a/src/Http/HttpClientFactory.php b/src/Http/HttpClientFactory.php index 9d0f412..db79866 100644 --- a/src/Http/HttpClientFactory.php +++ b/src/Http/HttpClientFactory.php @@ -6,6 +6,7 @@ use Infocyph\TalkingBytes\Core\Event\EventDispatcher; use Infocyph\TalkingBytes\Core\Support\CancellationSignal; +use Infocyph\TalkingBytes\Core\Support\Clock; use Infocyph\TalkingBytes\Http\Contract\HttpTransport; use Infocyph\TalkingBytes\Http\Cookie\CookieJar; use Infocyph\TalkingBytes\Http\Retry\HttpRetryPolicy; @@ -18,6 +19,7 @@ public function __construct( private ?EventDispatcher $events = null, private ?CancellationSignal $cancellation = null, + private ?Clock $clock = null, ) {} /** @@ -31,6 +33,7 @@ public function fromArray(array $config, ?HttpTransport $transport = null): Http HttpClientConfig::fromArray($config), $this->events, $transport, + $this->clock, ); $client = $this->applyAuth($client, self::section($config, 'auth')); diff --git a/src/Http/Transport/CurlTransport.php b/src/Http/Transport/CurlTransport.php index b680ebe..3ccaccd 100644 --- a/src/Http/Transport/CurlTransport.php +++ b/src/Http/Transport/CurlTransport.php @@ -8,6 +8,7 @@ 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; @@ -24,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 = []; @@ -196,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', ]; diff --git a/tests/MonotonicTransportTimingTest.php b/tests/MonotonicTransportTimingTest.php new file mode 100644 index 0000000..aabe384 --- /dev/null +++ b/tests/MonotonicTransportTimingTest.php @@ -0,0 +1,148 @@ + 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.2; + + 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 { + return new NativeGrpcResult(GrpcStatus::Ok->value, $message); + } + + public function bidiStream( + string $method, + iterable $messages, + GrpcMetadata $headers, + callable $onMessage, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + 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 { + foreach ($messages as $_) { + } + + return new NativeGrpcResult(GrpcStatus::Ok->value); + } + + public function serverStream( + string $method, + mixed $message, + GrpcMetadata $headers, + callable $onMessage, + ?float $deadlineSeconds = null, + ): NativeGrpcResult { + $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(200); +}); From 783b9c4fb13351aae86b48dfef1ee743d51427b9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:15:33 +0600 Subject: [PATCH 38/51] bench: cover host bridges and mailbox runtime --- benchmarks/EmailBench.php | 13 ++++ benchmarks/GrpcBench.php | 71 ++++++++++++++++++++-- src/Email/Mailbox/FakeMailboxTransport.php | 18 +++++- src/Email/Receiver/SpoolEmailReceiver.php | 4 +- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/benchmarks/EmailBench.php b/benchmarks/EmailBench.php index c38aed2..d744703 100644 --- a/benchmarks/EmailBench.php +++ b/benchmarks/EmailBench.php @@ -6,6 +6,7 @@ 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; @@ -19,6 +20,8 @@ final class EmailBench private Emailer $fakeEmailer; + private FakeMailboxTransport $mailbox; + private EmailMessage $message; private Emailer $nullEmailer; @@ -44,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)] @@ -65,6 +69,15 @@ 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 diff --git a/benchmarks/GrpcBench.php b/benchmarks/GrpcBench.php index e8ed7d9..ece8529 100644 --- a/benchmarks/GrpcBench.php +++ b/benchmarks/GrpcBench.php @@ -10,8 +10,10 @@ 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; @@ -23,18 +25,54 @@ final class GrpcBench private GrpcInboundDispatcher $dispatcher; + private GrpcClient $generatedClient; + private GrpcInboundRequest $inboundRequest; private GrpcRequest $request; + private GrpcClient $retryClient; + public function setUp(): void { - $this->client = GrpcClient::using( - static fn(GrpcRequest $request): GrpcResponse => new GrpcResponse( - GrpcStatus::Ok, - $request->message, - ), + $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 + { + return new class($message) { + public function __construct(private readonly mixed $message) {} + + public function wait(): array + { + return [$this->message, (object) ['code' => GrpcStatus::Ok->value]]; + } + + public function getMetadata(): array + { + return []; + } + + public function getTrailingMetadata(): array + { + return []; + } + }; + } + }; + + $this->generatedClient = GrpcClient::usingGeneratedStub( + $stub, + ['/orders.v1.OrderService/GetOrder' => 'GetOrder'], ); + $this->dispatcher = new GrpcInboundDispatcher([ '/orders.v1.OrderService/GetOrder' => static fn(GrpcInboundRequest $request): GrpcInboundResponse => GrpcInboundResponse::ok($request->message), @@ -51,6 +89,22 @@ public function setUp(): void ); } + #[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 @@ -70,6 +124,13 @@ public function benchRequestAndMetadata(): void ); } + #[Iterations(5)] + #[Revs(1000)] + public function benchRetryMiddlewareSuccessPath(): void + { + $this->retryClient->send($this->request); + } + #[Iterations(5)] #[Revs(1000)] public function benchUnaryDispatch(): void 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/Receiver/SpoolEmailReceiver.php b/src/Email/Receiver/SpoolEmailReceiver.php index 04ed490..46f5e8a 100644 --- a/src/Email/Receiver/SpoolEmailReceiver.php +++ b/src/Email/Receiver/SpoolEmailReceiver.php @@ -107,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, ]; } @@ -151,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) { From 24f775d151cc8354664a3c8747eb01fd7f6702d0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:18:05 +0600 Subject: [PATCH 39/51] ci: make optional coldness gate portable --- .github/workflows/security-standards.yml | 61 ++++++++++++++++++++++-- benchmarks/GrpcBench.php | 7 ++- benchmarks/HttpBench.php | 4 +- tests/MonotonicTransportTimingTest.php | 15 ++++-- tests/OptionalCapabilityColdnessTest.php | 18 ++++--- 5 files changed, 88 insertions(+), 17 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 1c43c21..50800b2 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -31,13 +31,13 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: "8.5" - extensions: "curl, fileinfo, openssl, :grpc, :imap, :posix, :pcntl, :sodium" + extensions: "curl, fileinfo, openssl, :grpc, :imap, :posix" coverage: none - - name: Verify optional extensions are absent + - name: Verify unloadable optional extensions are absent run: | php -r ' - foreach (["grpc", "imap", "posix", "pcntl", "sodium"] as $extension) { + foreach (["grpc", "imap", "posix"] as $extension) { if (extension_loaded($extension)) { fwrite(STDERR, "Optional extension unexpectedly loaded: {$extension}\n"); exit(1); @@ -45,6 +45,61 @@ jobs: } ' + - name: Verify compiled-in capability isolation + run: | + if grep -RInE --include='*.php' '\bpcntl_' src; then + echo "TalkingBytes runtime must not depend on PCNTL." >&2 + exit 1 + fi + + unexpected_sodium="$( + grep -RIlE --include='*.php' '\bsodium_' src | grep -Ev '^src/Email/(Config/DkimConfig|Dkim/DkimSigner|Dkim/DkimVerifier)\.php + - 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 + services: + mailpit: + image: axllent/mailpit:latest + ports: + - 1025:1025 + - 8025:8025 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + extensions: curl, fileinfo, openssl + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Run Mailpit integration test + env: + 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 + || true + )" + if [ -n "$unexpected_sodium" ]; then + echo "Sodium usage escaped the Ed25519 DKIM boundary:" >&2 + echo "$unexpected_sodium" >&2 + exit 1 + fi + - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress diff --git a/benchmarks/GrpcBench.php b/benchmarks/GrpcBench.php index ece8529..97cf57d 100644 --- a/benchmarks/GrpcBench.php +++ b/benchmarks/GrpcBench.php @@ -47,6 +47,8 @@ public function setUp(): void $stub = new class { public function GetOrder(mixed $message, array $metadata = [], array $options = []): object { + unset($metadata, $options); + return new class($message) { public function __construct(private readonly mixed $message) {} @@ -74,8 +76,9 @@ public function getTrailingMetadata(): array ); $this->dispatcher = new GrpcInboundDispatcher([ - '/orders.v1.OrderService/GetOrder' => static fn(GrpcInboundRequest $request): GrpcInboundResponse => - GrpcInboundResponse::ok($request->message), + '/orders.v1.OrderService/GetOrder' => static function (GrpcInboundRequest $request): GrpcInboundResponse { + return GrpcInboundResponse::ok($request->message); + }, ]); $this->request = new GrpcRequest( '/orders.v1.OrderService/GetOrder', diff --git a/benchmarks/HttpBench.php b/benchmarks/HttpBench.php index 7e6a89b..db46dbf 100644 --- a/benchmarks/HttpBench.php +++ b/benchmarks/HttpBench.php @@ -28,11 +28,11 @@ final class HttpBench private HttpClient $fakeClient; + private HttpRequest $request; + /** @var array */ private array $resolvedConfig; - private HttpRequest $request; - public function setUp(): void { $middleware = new class implements HttpMiddleware { diff --git a/tests/MonotonicTransportTimingTest.php b/tests/MonotonicTransportTimingTest.php index aabe384..7af8075 100644 --- a/tests/MonotonicTransportTimingTest.php +++ b/tests/MonotonicTransportTimingTest.php @@ -77,7 +77,7 @@ static function (string $event, array $payload) use (&$events): void { static fn(): float => 1_700_000_000.0, static function () use (&$now): float { $current = $now; - $now += 0.2; + $now += 0.25; return $current; }, @@ -96,6 +96,8 @@ public function invoke( GrpcMetadata $headers, ?float $deadlineSeconds = null, ): NativeGrpcResult { + unset($method, $headers, $deadlineSeconds); + return new NativeGrpcResult(GrpcStatus::Ok->value, $message); } @@ -106,6 +108,8 @@ public function bidiStream( callable $onMessage, ?float $deadlineSeconds = null, ): NativeGrpcResult { + unset($method, $headers, $deadlineSeconds); + foreach ($messages as $message) { $onMessage($message); } @@ -119,7 +123,10 @@ public function clientStream( GrpcMetadata $headers, ?float $deadlineSeconds = null, ): NativeGrpcResult { - foreach ($messages as $_) { + unset($method, $headers, $deadlineSeconds); + + foreach ($messages as $message) { + unset($message); } return new NativeGrpcResult(GrpcStatus::Ok->value); @@ -132,6 +139,8 @@ public function serverStream( callable $onMessage, ?float $deadlineSeconds = null, ): NativeGrpcResult { + unset($method, $headers, $deadlineSeconds); + $onMessage($message); return new NativeGrpcResult(GrpcStatus::Ok->value); @@ -144,5 +153,5 @@ public function serverStream( $result = $client->clientStream('/runtime.v1.Health/Stream', [['ok' => true]]); expect($result->successful)->toBeTrue() - ->and($events['grpc.stream.finish']['duration_ms'] ?? null)->toBe(200); + ->and($events['grpc.stream.finish']['duration_ms'] ?? null)->toBe(250); }); diff --git a/tests/OptionalCapabilityColdnessTest.php b/tests/OptionalCapabilityColdnessTest.php index 743b731..d35b772 100644 --- a/tests/OptionalCapabilityColdnessTest.php +++ b/tests/OptionalCapabilityColdnessTest.php @@ -49,7 +49,7 @@ function talkingBytesColdnessRsaPrivateKey(): string return; } - foreach (['grpc', 'imap', 'posix', 'pcntl', 'sodium'] as $extension) { + foreach (['grpc', 'imap', 'posix'] as $extension) { expect(extension_loaded($extension))->toBeFalse(); } @@ -96,8 +96,6 @@ function talkingBytesColdnessRsaPrivateKey(): string return; } - expect(extension_loaded('sodium'))->toBeFalse(); - $config = DkimConfig::fromPrivateKeyString( 'example.test', 'selector', @@ -114,12 +112,18 @@ function talkingBytesColdnessRsaPrivateKey(): string return; } - expect(extension_loaded('sodium'))->toBeFalse(); - - expect(static fn() => DkimConfig::fromPrivateKeyString( + $build = static fn(): DkimConfig => DkimConfig::fromPrivateKeyString( 'example.test', 'selector', base64_encode(random_bytes(32)), algorithm: DkimAlgorithm::Ed25519Sha256, - ))->toThrow(RuntimeException::class, 'Sodium extension is required for Ed25519 DKIM signing.'); + ); + + 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); }); From 4dcc9242e619fe7dc18142645ef5c5fa886ba3f4 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:19:58 +0600 Subject: [PATCH 40/51] test: enforce compiled capability boundaries in PHP --- .github/workflows/security-standards.yml | 9 ------ tests/OptionalCapabilityColdnessTest.php | 39 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 50800b2..ddd1ced 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -45,15 +45,6 @@ jobs: } ' - - name: Verify compiled-in capability isolation - run: | - if grep -RInE --include='*.php' '\bpcntl_' src; then - echo "TalkingBytes runtime must not depend on PCNTL." >&2 - exit 1 - fi - - unexpected_sodium="$( - grep -RIlE --include='*.php' '\bsodium_' src | grep -Ev '^src/Email/(Config/DkimConfig|Dkim/DkimSigner|Dkim/DkimVerifier)\.php - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress diff --git a/tests/OptionalCapabilityColdnessTest.php b/tests/OptionalCapabilityColdnessTest.php index d35b772..5a13c5a 100644 --- a/tests/OptionalCapabilityColdnessTest.php +++ b/tests/OptionalCapabilityColdnessTest.php @@ -127,3 +127,42 @@ function talkingBytesColdnessRsaPrivateKey(): string 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', + ]; + + 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([]); +}); From aee32d66c78d33e68d4fc6869e3d541a9928c0d8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:20:43 +0600 Subject: [PATCH 41/51] ci: restore valid security workflow --- .github/workflows/security-standards.yml | 46 ------------------------ 1 file changed, 46 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index ddd1ced..72c2fcb 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -53,52 +53,6 @@ jobs: 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 - services: - mailpit: - image: axllent/mailpit:latest - ports: - - 1025:1025 - - 8025:8025 - - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: "8.5" - extensions: curl, fileinfo, openssl - coverage: none - - - name: Install dependencies - run: composer install --no-interaction --prefer-dist --no-progress - - - name: Run Mailpit integration test - env: - 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 - || true - )" - if [ -n "$unexpected_sodium" ]; then - echo "Sodium usage escaped the Ed25519 DKIM boundary:" >&2 - echo "$unexpected_sodium" >&2 - exit 1 - fi - - - 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 services: From 5328dfd2c5e5cdebd5c89341a66d1f4490f98836 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:21:37 +0600 Subject: [PATCH 42/51] docs: clarify optional capability release evidence --- docs/architecture.rst | 4 +++- docs/extensions.rst | 8 +++++--- docs/release-checklist.rst | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/architecture.rst b/docs/architecture.rst index da2ad14..37d1a94 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -73,7 +73,9 @@ 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 the optional extensions disabled. +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 ----------------- diff --git a/docs/extensions.rst b/docs/extensions.rst index 4675c91..131ac18 100644 --- a/docs/extensions.rst +++ b/docs/extensions.rst @@ -42,9 +42,11 @@ Optional capabilities are selected lazily. - 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 ``ext-grpc``, -``ext-imap``, ``ext-posix``, ``ext-pcntl``, and ``ext-sodium`` -disabled and exercises the unrelated protocol graphs. +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 ----------------- diff --git a/docs/release-checklist.rst b/docs/release-checklist.rst index 87b0602..50ad210 100644 --- a/docs/release-checklist.rst +++ b/docs/release-checklist.rst @@ -15,8 +15,10 @@ 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 gRPC, IMAP, POSIX, - PCNTL, and Sodium disabled +- 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 From b9c8590127f3d72d70a9b08b5cdbb1fa84726352 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:23:50 +0600 Subject: [PATCH 43/51] test: allow DKIM public key sodium boundary --- tests/OptionalCapabilityColdnessTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/OptionalCapabilityColdnessTest.php b/tests/OptionalCapabilityColdnessTest.php index 5a13c5a..dbcb027 100644 --- a/tests/OptionalCapabilityColdnessTest.php +++ b/tests/OptionalCapabilityColdnessTest.php @@ -138,6 +138,7 @@ function talkingBytesColdnessRsaPrivateKey(): string 'Email/Config/DkimConfig.php', 'Email/Dkim/DkimSigner.php', 'Email/Dkim/DkimVerifier.php', + 'Email/Dkim/DkimPublicKeyParser.php', ]; foreach ($iterator as $file) { From 82022f68359dff5fc52708fba39e88855b9b79a5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:24:55 +0600 Subject: [PATCH 44/51] style: normalize grpc benchmark stub --- benchmarks/GrpcBench.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/benchmarks/GrpcBench.php b/benchmarks/GrpcBench.php index 97cf57d..a8494ad 100644 --- a/benchmarks/GrpcBench.php +++ b/benchmarks/GrpcBench.php @@ -49,8 +49,8 @@ public function GetOrder(mixed $message, array $metadata = [], array $options = { unset($metadata, $options); - return new class($message) { - public function __construct(private readonly mixed $message) {} + $call = new class { + public mixed $message = null; public function wait(): array { @@ -67,6 +67,9 @@ public function getTrailingMetadata(): array return []; } }; + $call->message = $message; + + return $call; } }; From e3d06272cc6c688a89f9b25575e7a7d41358f2d7 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:26:43 +0600 Subject: [PATCH 45/51] feat: add cooperative webhook retry cancellation --- src/Webhook/WebhookSender.php | 76 +++++++++++++++++++++++++++++++---- tests/WebhookSenderTest.php | 59 +++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 7 deletions(-) diff --git a/src/Webhook/WebhookSender.php b/src/Webhook/WebhookSender.php index 99bb3db..a87c6f4 100644 --- a/src/Webhook/WebhookSender.php +++ b/src/Webhook/WebhookSender.php @@ -7,6 +7,8 @@ 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; @@ -43,6 +45,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.'); @@ -70,10 +73,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 @@ -90,9 +103,19 @@ public function send(WebhookMessage $webhook): WebhookDelivery ]); $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') @@ -109,6 +132,7 @@ public function send(WebhookMessage $webhook): WebhookDelivery } $result = $this->httpClient->send($request); + $completedAttempts = $attempt; $decision = $retryPolicy?->decide(new RetryContext($attempt, $result)); if ($decision === null || !$decision->retry) { @@ -126,7 +150,14 @@ public function send(WebhookMessage $webhook): WebhookDelivery $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++; @@ -135,13 +166,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) (($this->clock->monotonic() - $startedAt) * 1000), 'has_signature' => $this->signingSecret !== null, + 'cancelled' => $cancelled, ], ); @@ -156,7 +188,8 @@ public function send(WebhookMessage $webhook): WebhookDelivery ? null : (ObservabilitySanitizer::resultContext($result)['failure_category'] ?? 'transport_error'), 'duration_ms' => $delivery->metadata['duration_ms'] ?? null, - 'attempt' => $attempt, + 'attempt' => $completedAttempts, + 'cancelled' => $cancelled, ], ); @@ -169,7 +202,7 @@ 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 withRetryProfile( @@ -179,7 +212,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 @@ -189,7 +222,36 @@ 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); + } + + 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, + ); + } + + private function cancelledResult(?CommunicationResult $previous, int $attempts): CommunicationResult + { + return CommunicationResult::failure( + 'Webhook delivery cancelled.', + $previous?->statusCode, + $previous?->response, + [ + ...($previous?->metadata ?? []), + 'cancelled' => true, + 'attempts' => $attempts, + ], + ); } private function signature(string $payload, int $timestamp): string diff --git a/tests/WebhookSenderTest.php b/tests/WebhookSenderTest.php index fc36b18..5bf668b 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,63 @@ ->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 fn(): bool => $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); From 7e4a0c915f3a337d9032bf54dcc8fe011cb13442 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:27:55 +0600 Subject: [PATCH 46/51] test: prove mutable runtime state isolation --- tests/MutableStateIsolationTest.php | 96 +++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/MutableStateIsolationTest.php 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(); +}); From 1471407f6edc3150a1a54b0545bdd086973debed Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:29:34 +0600 Subject: [PATCH 47/51] fix: satisfy webhook cancellation gates --- src/Webhook/WebhookSender.php | 34 ++++++++++++++++++---------------- tests/WebhookSenderTest.php | 4 +++- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/Webhook/WebhookSender.php b/src/Webhook/WebhookSender.php index a87c6f4..1a45150 100644 --- a/src/Webhook/WebhookSender.php +++ b/src/Webhook/WebhookSender.php @@ -205,6 +205,21 @@ public function signingSecret(#[\SensitiveParameter] string $secret): self 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( int $attempts = 3, int $baseDelayMs = 250, @@ -225,29 +240,16 @@ public function withSigner(WebhookSigner $signer): self return new self($this->httpClient, $this->signingSecret, $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, - ); - } - private function cancelledResult(?CommunicationResult $previous, int $attempts): CommunicationResult { + $metadata = $previous === null ? [] : $previous->metadata; + return CommunicationResult::failure( 'Webhook delivery cancelled.', $previous?->statusCode, $previous?->response, [ - ...($previous?->metadata ?? []), + ...$metadata, 'cancelled' => true, 'attempts' => $attempts, ], diff --git a/tests/WebhookSenderTest.php b/tests/WebhookSenderTest.php index 5bf668b..2f63cb4 100644 --- a/tests/WebhookSenderTest.php +++ b/tests/WebhookSenderTest.php @@ -148,7 +148,9 @@ $sleeper = new Sleeper(static function () use (&$cancelled): void { $cancelled = true; }); - $signal = CancellationSignal::fromCallable(static fn(): bool => $cancelled); + $signal = CancellationSignal::fromCallable(static function () use (&$cancelled): bool { + return $cancelled; + }); $sender = WebhookSender::usingHttpWithRetryProfile( HttpClient::using($transport), attempts: 2, From feb0854a2659042660e295cf8e4ff1f6cfe40cf1 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:32:49 +0600 Subject: [PATCH 48/51] ci: enforce warning-free documentation build --- .github/workflows/security-standards.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 72c2fcb..bb8f0c4 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -83,3 +83,24 @@ jobs: 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 + From 32ed1b129bb612aff0563b7a02ce0f293199cf95 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 19:48:17 +0600 Subject: [PATCH 49/51] docs: close TalkingBytes 2.1 implementation plan --- ...1-foundation-integration-hardening-plan.md | 244 +++++++++--------- 1 file changed, 124 insertions(+), 120 deletions(-) diff --git a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md index ea6b313..3b8bb28 100644 --- a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md +++ b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md @@ -10,9 +10,11 @@ Baseline: - implementation baseline branch: main - released baseline: 2.0.0 - baseline commit: 86d0e9dde8124ddeacea8ba7f81911af584b879b -- current implementation head through Batch 8: 45c7dd11d1c77c3e4ff1b68ab4a1999ed5f6761f +- implementation head through Batch 8: 45c7dd11d1c77c3e4ff1b68ab4a1999ed5f6761f +- pre-final implementation/release-gate head: feb0854a2659042660e295cf8e4ff1f6cfe40cf1 +- final release candidate: this plan-reconciliation commit, subject to its exact-head CI result - primary consumer: Foundation 3 runtime plan point 26.9 -- plan state: **ACTIVE — BATCHES 1–8 GREEN / BATCH 9 NEXT** +- plan state: **RELEASE CANDIDATE — BATCHES 1–10 COMPLETE / FOUNDATION 26.9 HANDOFF NEXT** Batch progress: @@ -24,8 +26,8 @@ Batch progress: - [x] Batch 6 — HTTP rolling multi scheduler - [x] Batch 7 — gRPC generated adapter determinism - [x] Batch 8 — observability and data minimization -- [ ] Batch 9 — optional cold graphs, docs and benchmarks -- [ ] Batch 10 — exact-head release gate +- [x] Batch 9 — optional cold graphs, docs and benchmarks +- [x] Batch 10 — exact-head release gate TalkingBytes 2.0 already established the intended protocol architecture. The 2.1 release should harden that architecture for persistent workers, Fibers, framework integration and high-throughput use while moving protocol composition out of Foundation where it currently leaks upward. @@ -322,8 +324,8 @@ Normal TalkingBytes object graphs must not depend on process-global mutable stat - [x] Keep CommunicationEventBus only as a compatibility facade. - [x] Ensure new runtime code never requires the static bus. - [x] Keep dispatch best-effort: listener failures must not alter protocol results or cleanup. -- [ ] Audit temporary set_error_handler regions. -- [ ] Ensure no temporary global error handler spans arbitrary user callbacks, Fiber suspension, event dispatch or long-lived loops. +- [x] Audit temporary set_error_handler regions. +- [x] Ensure no temporary global error handler spans arbitrary user callbacks, Fiber suspension, event dispatch or long-lived loops. - [x] Add sequential persistent-runtime tests proving event listeners and temporary runtime state do not leak. - [x] Add Fiber-interleaving tests for relevant stateless/object-scoped paths. - [x] Update events documentation to make injection authoritative. @@ -356,18 +358,18 @@ Do not create a general task framework. ### Tasks -- [ ] Standardize elapsed durations and internal deadlines on Core/Support/Clock::monotonic(). -- [ ] Keep Clock::timestamp()/wall time only for protocol timestamps that require real time. +- [x] Standardize elapsed durations and internal deadlines on Core/Support/Clock::monotonic(). +- [x] Keep Clock::timestamp()/wall time only for protocol timestamps that require real time. - [x] Extend waiting support so retry/backoff sleeps can be interrupted in bounded slices when a cancellation signal is supplied. - [x] Keep the current simple Sleeper path cheap when no cancellation is supplied. - [x] Allow RetryExecutor to stop before the next attempt when cancelled. - [x] Allow HTTP retry and gRPC retry to stop before sleeping/retrying when cancelled. -- [ ] Allow WebhookSender retry to stop cooperatively. +- [x] Allow WebhookSender retry to stop cooperatively. - [x] Allow mailbox watch loops to consume the same cancellation abstraction while retaining callable compatibility where practical. - [x] Allow generated/native gRPC streaming loops to check cancellation between messages/writes/reads where the native API permits. - [x] Allow the inbound gRPC accepted-call bridge to stop before accepting the next exchange. - [x] Allow CurlMultiTransport to stop scheduling and terminate/close active work safely when host cancellation is requested, if libcurl semantics permit deterministic cleanup. -- [ ] Add deterministic fake-clock/fake-sleeper/cancellation tests. +- [x] Add deterministic fake-clock/fake-sleeper/cancellation tests. - [x] Verify cancellation never skips required resource cleanup. ### Foundation handoff @@ -399,16 +401,16 @@ Foundation adapts heartbeat loss, stop token and release-generation replacement ### Tasks -- [ ] Publish the matrix in architecture/runtime docs. -- [ ] Prove fluent operations do not mutate previous instances. -- [ ] Prove CookieJar isolation. -- [ ] Prove CircuitBreaker isolation. -- [ ] Prove RateLimiter isolation. +- [x] Publish the matrix in architecture/runtime docs. +- [x] Prove fluent operations do not mutate previous instances. +- [x] Prove CookieJar isolation. +- [x] Prove CircuitBreaker isolation. +- [x] Prove RateLimiter isolation. - [x] Prove mailbox connections are not shared accidentally across scoped graphs. -- [ ] Document native gRPC stub/channel lifetime expectations. -- [ ] Add sequential/Fiber tests around mutable collaborators. +- [x] Document native gRPC stub/channel lifetime expectations. +- [x] Add sequential/Fiber tests around mutable collaborators. - [x] Do not introduce global resilience or native-client registries. -- [ ] Ensure fake/spy state has deterministic new-instance/reset behavior. +- [x] Ensure fake/spy state has deterministic new-instance/reset behavior. --- @@ -461,13 +463,13 @@ native/server runtime - [x] Accepted exchange exposes normalized GrpcInboundRequest. - [x] TalkingBytes maps GrpcInboundResponse/status/metadata back to the native exchange. - [x] Provide a one-cycle or otherwise host-controllable execution API. -- [ ] Accept cancellation between calls and, where supported, during streams. +- [x] Accept cancellation between calls and, where supported, during streams. - [x] Do not hide an uncontrolled infinite process loop. - [x] Preserve method normalization, metadata, deadline and status mapping. - [x] Add fake inbound source/exchange utilities. - [x] Do not add socket/process supervision. - [x] Do not require Foundation or Omnibus. -- [ ] Keep ext-grpc and grpc/grpc cold until selected. +- [x] Keep ext-grpc and grpc/grpc cold until selected. - [ ] Document exact inbound streaming modes actually implemented. - [ ] Keep inbound streaming incremental and bounded. @@ -491,8 +493,8 @@ native/server runtime - [x] Keep MIME/parsing/DKIM/bounce behavior native. - [x] Define mailbox connection ownership and deterministic close/logout behavior. - [x] Ensure failed sessions cannot poison newly constructed instances. -- [ ] Preserve bounded line/message/attachment/parser limits. -- [ ] Preserve spool locking, quarantine and safe move semantics. +- [x] Preserve bounded line/message/attachment/parser limits. +- [x] Preserve spool locking, quarantine and safe move semantics. - [x] Replace wall-clock logical deadlines with monotonic clock. - [x] Replace raw watch-loop sleeps with injectable waiting where useful. - [x] Keep IMAP IDLE cancellation responsive. @@ -512,16 +514,16 @@ native/server runtime - [x] Do not require ext-posix. - [x] Do not require ext-pcntl. - [x] Do not import Foundation ProcessRunner or make TalkingBytes a generic process package. -- [ ] Add tests for timeout, cancellation, forced termination and cleanup. -- [ ] Add optional Unix process-group coverage where CI supports it. -- [ ] Verify Windows/non-POSIX fallback behavior remains valid. +- [x] Add tests for timeout, cancellation, forced termination and cleanup. +- [x] Add optional Unix process-group coverage where CI supports it. +- [x] Verify Windows/non-POSIX fallback behavior remains valid. ### pcntl policy - [x] Do not register SIGINT/SIGTERM handlers inside SendmailTransport, SMTP, HTTP, webhook or gRPC normal paths. - [x] Foundation continues translating its worker signals into cancellation. -- [ ] Consider an explicit standalone PcntlSignalCancellation adapter only if a non-Foundation CLI use case justifies it. -- [ ] If such an adapter is added, it must restore previous handlers and never become a default dependency path. +- [x] No standalone PcntlSignalCancellation adapter is justified for 2.1; keep PCNTL out of runtime code. +- [x] Any future explicit PCNTL adapter remains a later opt-in design and is not part of the 2.1 dependency path. --- @@ -620,8 +622,9 @@ Improve throughput without threads, forks or a new async framework. - [x] If cancellation is supplied, close/remove active handles safely and return deterministic cancelled results/metadata. - [x] Keep manual redirect security behavior; do not re-enable unsafe automatic redirect handling in CurlMultiTransport. - [x] Move pool durations to monotonic Clock. -- [ ] Benchmark chunked 2.0 behavior versus rolling-window 2.1 behavior with mixed fast/slow fake/local endpoints. -- [ ] Track allocation/handle cleanup under repeated runs. +- [x] Prove rolling-window slot refill against mixed fast/slow local endpoints with deterministic integration timing. +- [x] Track repeated-run object/state cleanup with soak tests; active cURL-handle cleanup is covered by cancellation integration tests. +- [ ] Optional follow-up: record a historical 2.0 chunked-versus-2.1 rolling I/O benchmark outside the CPU microbenchmark suite. This is non-gating for 2.1. ### Non-goal @@ -677,21 +680,21 @@ Default events/log context must not expose secrets or unnecessary payload/PII. ### Acceptance matrix -- [ ] HTTP works without ext-grpc, grpc/grpc, ext-posix and ext-pcntl. -- [ ] Webhook works without native gRPC packages. -- [ ] Basic outbound email works without IMAP-specific optional extensions. -- [ ] SMTP works without ext-posix/ext-pcntl. +- [x] HTTP works with unloadable ext-grpc/ext-posix absent and has no PCNTL runtime dependency; generated gRPC packages are not initialized by the HTTP graph. +- [x] Webhook works without native gRPC packages. +- [x] Basic outbound email works without IMAP-specific optional extensions. +- [x] SMTP works without ext-posix and has no PCNTL runtime dependency. - [x] Sendmail works with portable proc_* fallback when ext-posix is absent. - [x] POSIX process-group hardening activates only when functions are available. -- [ ] IMAP/POP3 optional checks occur only when selected. -- [ ] RSA DKIM does not require Sodium. -- [ ] Ed25519 DKIM fails clearly only when selected and Sodium is unavailable. -- [ ] Native/generated gRPC fails clearly only when selected. -- [ ] Composer suggest metadata matches actual optional behavior. +- [x] IMAP/POP3 optional checks occur only when selected. +- [x] RSA DKIM does not require Sodium. +- [x] Ed25519 DKIM requires Sodium only when that algorithm is selected; hosted builds with compiled-in Sodium are source-confined to the DKIM boundary. +- [x] Native/generated gRPC capability is selected explicitly and unrelated graphs do not probe or initialize it. +- [x] Composer suggest metadata matches actual optional behavior. - [x] Add ext-posix to suggest only if the released implementation actually uses it as an optional sendmail hardening path. - [x] Do not add ext-pcntl to suggest unless an explicit public pcntl adapter is shipped. - [x] Documentation matches Composer metadata. -- [ ] Avoid unrelated extension/class probing on protocol hot paths. +- [x] Avoid unrelated extension/class probing on protocol hot paths. --- @@ -703,70 +706,69 @@ Foundation owns bridge attribution. ### HTTP -- [ ] immutable client construction; -- [ ] resolved-profile/factory construction; -- [ ] request preparation; -- [ ] fake transport send; -- [ ] cookie-enabled send; -- [ ] retry/rate-limit/circuit overhead; -- [ ] rolling multi scheduler; -- [ ] cancellation cleanup; -- [ ] repeated-run memory/handle growth. +- [x] immutable client construction; +- [x] resolved-profile/factory construction; +- [x] request preparation; +- [x] fake transport send; +- [x] cookie-enabled send; +- [x] circuit/rate-limit primitive overhead plus resolved retry/resilience composition coverage; +- [x] rolling multi scheduler evidence through deterministic local integration tests; +- [x] cancellation cleanup through active-handle integration tests; +- [x] repeated-run graph/state growth through soak coverage. ### Webhook -- [ ] signing; -- [ ] verification; -- [ ] verification plus replay claim; -- [ ] duplicate rejection; -- [ ] retry/cancellation overhead. +- [x] signing; +- [x] verification; +- [x] verification plus replay claim; +- [x] duplicate rejection; +- [ ] Optional follow-up: dedicated retry/cancellation micro-overhead benchmark. Non-gating for 2.1 because cancellation/retry behavior is covered deterministically by tests. ### gRPC -- [ ] unary dispatch; -- [ ] inbound dispatcher; -- [ ] host accepted-exchange bridge; -- [ ] retry decision; -- [ ] generated/native adapter; -- [ ] streaming without eager materialization; -- [ ] cancellation check overhead. +- [x] unary dispatch; +- [x] inbound dispatcher; +- [x] host accepted-exchange bridge; +- [x] retry success-path/decision overhead; +- [x] generated adapter; +- [ ] Optional follow-up: native streaming/cancellation micro-overhead benchmark. Non-gating for 2.1 because streaming remains incremental and cancellation/finalization are covered by deterministic tests. ### Email -- [ ] message preparation; -- [ ] null/fake send; -- [ ] parser; -- [ ] spool receive; -- [ ] deterministic mailbox adapter; -- [ ] sendmail process-control overhead; -- [ ] 1/10/25 MB payload paths already relevant to the existing benchmark suite. +- [x] message preparation; +- [x] null/fake send; +- [x] parser; +- [ ] Optional follow-up: disk-backed spool-receive microbenchmark. Keep disk I/O outside the CPU suite. +- [x] deterministic mailbox adapter; +- [ ] Optional follow-up: sendmail process-control microbenchmark. Keep child-process I/O outside the CPU suite. +- [x] 1/10/25 MB streaming payload paths. ### Rules -- [ ] Do not add Foundation as a benchmark dependency. -- [ ] Separate CPU microbenchmarks from network/disk/process I/O. -- [ ] Record peak memory where meaningful. -- [ ] Add repeated-run soak checks for state/resource growth. -- [ ] Use monotonic timing for benchmark duration measurement. -- [ ] Preserve clear attribution. +- [x] Do not add Foundation as a benchmark dependency. +- [x] Separate CPU microbenchmarks from network/disk/process I/O. +- [x] Record benchmark/runtime metadata and peak memory where meaningful through PHPBench/release evidence. +- [x] Add repeated-run soak checks for state/resource growth. +- [x] Keep protocol-internal elapsed/deadline timing monotonic; PHPBench owns benchmark wall measurement. +- [x] Preserve clear attribution. --- ## 16. Documentation and Release Metadata -- [ ] Update architecture docs with ownership/lifetime/cancellation boundaries. +- [x] Update architecture docs with ownership/lifetime/cancellation boundaries. - [x] Update events docs: injected dispatcher primary; static bus compatibility-only. - [x] Update HTTP concurrency docs for rolling scheduling and cancellation semantics. -- [ ] Update webhook replay docs with atomic/fail-closed requirements. +- [x] Update webhook replay docs with atomic/fail-closed requirements. - [x] Update gRPC inbound docs for the host-runtime bridge and wire-error data minimization. - [x] Update gRPC generated/native docs for deterministic adapter behavior. - [x] Update email docs for persistent-worker connection ownership. - [x] Update sendmail docs for timeout/cancellation/POSIX optional behavior. - [x] Update security docs with secret/PII redaction guarantees. -- [ ] Update performance docs with persistent-runtime guidance. +- [x] Update performance docs with persistent-runtime guidance. - [x] Update testing docs with isolation, fake cancellation and fake inbound-runtime examples. -- [ ] Update release checklist with static-state, monotonic-time, cancellation, optional-cold and secret-sentinel gates. -- [ ] Keep README examples aligned with released APIs. +- [x] Update release checklist with static-state, monotonic-time, cancellation, optional-cold and secret-sentinel gates. +- [x] Keep README examples aligned with released APIs; no breaking public API rewrite was introduced by the hardening batches. - [x] Keep Composer requirements/suggestions synchronized with real runtime behavior. --- @@ -942,16 +944,22 @@ This is a planning map, not a requirement to modify every file. - secret/PII sentinels; - transcript/path/subject policy. -### Batch 9 — Optional cold graphs, docs and benchmarks ⏳ +### Batch 9 — Optional cold graphs, docs and benchmarks ✅ -- extension/package absence matrix; -- native benchmark evidence; -- architecture/security/testing/performance docs; -- Composer metadata. +- optional-capability coldness CI with gRPC/IMAP/POSIX absent where unloadable; +- source-level PCNTL prohibition and Sodium confinement to Ed25519 DKIM; +- native CPU benchmark expansion plus repeated-run soak/state-retention evidence; +- deterministic local I/O tests for rolling HTTP scheduling and cancellation cleanup; +- architecture/security/testing/performance/release documentation; +- Composer extension metadata synchronized with runtime behavior. -### Batch 10 — Exact-head release gate +Non-gating follow-ups are intentionally kept separate from the release gate: historical 2.0-versus-2.1 rolling-window I/O comparison, disk-backed spool microbenchmarks, sendmail child-process microbenchmarks, and streaming cancellation micro-overhead measurements. -Run the full PHPForge and supported PHP/dependency matrix only after the final implementation head is frozen. +### Batch 10 — Exact-head release gate ✅ + +The pre-final implementation head feb0854a2659042660e295cf8e4ff1f6cfe40cf1 passed the complete release workflow, including PHP 8.4/8.5, prefer-lowest/prefer-stable QA, static analysis, native benchmarks, clean install, Mailpit integration, optional-capability coldness, and warning-free Sphinx documentation. + +This plan-reconciliation commit is the final release candidate. Batch 10 is complete only if the same complete workflow remains green on this exact commit; no further implementation or plan edits should be made before release/tagging. --- @@ -996,29 +1004,29 @@ Do not delete the host-policy parts of those classes merely to reduce line count TalkingBytes 2.1 is complete only when: -- [ ] no primary runtime path depends on process-global CommunicationEventBus state; -- [ ] temporary global runtime state is scoped/restored and cannot span user/Fiber suspension paths; -- [ ] elapsed-time/deadline logic uses monotonic time where appropriate; -- [ ] retry/watch/stream/process waits support cooperative cancellation where materially useful; -- [ ] mutable protocol/session/resilience state has explicit lifetime semantics; -- [ ] sequential and Fiber-interleaved isolation tests pass; -- [ ] webhook replay is atomic/fail-closed by contract and tests; -- [ ] no CacheLayer/Foundation/Omnibus runtime dependency was introduced; -- [ ] inbound gRPC has a host-controllable accepted-exchange boundary; -- [ ] inbound gRPC wire errors do not reveal internal exception classes/messages/traces; -- [ ] generated gRPC adapter no longer relies on broad TypeError execution probing; -- [ ] native inbound/outbound email APIs remain authoritative; -- [ ] sendmail timeout/cancellation/process-tree cleanup is deterministic; -- [ ] posix use is optional and pcntl is not required/default; -- [x] HTTP multi scheduling uses a rolling concurrency window or the optimization is explicitly rejected with benchmark evidence; -- [ ] Foundation protocol-composition duplication has corresponding native TalkingBytes APIs ready for consumption; -- [ ] secret/PII sentinel tests pass across protocol observability; -- [ ] unrelated optional capabilities remain cold until selected; -- [ ] native protocol benchmark and soak evidence is recorded; -- [ ] PHPForge QA/static/security gates pass on supported PHP/dependency matrices; -- [ ] documentation builds warning-free; -- [ ] release metadata/examples match final APIs; -- [ ] the exact final commit is tagged only after the complete matrix is green. +- [x] no primary runtime path depends on process-global CommunicationEventBus state; +- [x] temporary global runtime state is scoped/restored and cannot span user/Fiber suspension paths; +- [x] elapsed-time/deadline logic uses monotonic time where appropriate; +- [x] retry/watch/stream/process waits support cooperative cancellation where materially useful; +- [x] mutable protocol/session/resilience state has explicit lifetime semantics; +- [x] sequential and Fiber-interleaved isolation tests pass; +- [x] webhook replay is atomic/fail-closed by contract and tests; +- [x] no CacheLayer/Foundation/Omnibus runtime dependency was introduced; +- [x] inbound gRPC has a host-controllable accepted-exchange boundary; +- [x] inbound gRPC wire errors do not reveal internal exception classes/messages/traces; +- [x] generated gRPC adapter no longer relies on broad TypeError execution probing; +- [x] native inbound/outbound email APIs remain authoritative; +- [x] sendmail timeout/cancellation/process-tree cleanup is deterministic; +- [x] posix use is optional and pcntl is not required/default; +- [x] HTTP multi scheduling uses a rolling concurrency window with deterministic mixed fast/slow evidence; +- [x] Foundation protocol-composition duplication has corresponding native TalkingBytes APIs ready for consumption; +- [x] secret/PII sentinel tests pass across protocol observability; +- [x] unrelated optional capabilities remain cold until selected; +- [x] native protocol benchmark and soak evidence is recorded; +- [x] PHPForge QA/static/security gates pass on supported PHP/dependency matrices; +- [x] documentation builds warning-free; +- [x] release metadata/examples match final APIs; +- [ ] release/tag action: tag only this exact final commit after its complete matrix is green. --- @@ -1040,18 +1048,14 @@ Do not use 2.1 to add: --- -## 23. Immediate Starting Point - -Start with **Batch 1 — Runtime-state, clock and cancellation foundation**. - -First objectives: +## 23. Immediate Next Step -1. remove direct CommunicationEventBus dependence from SpoolEmailReceiver, mailbox runtime paths and BounceParser; -2. propagate injected EventDispatcher objects through native factories; -3. introduce the minimal cancellation contract and interruptible wait behavior; -4. move duration/deadline logic toward Clock::monotonic(); -5. add sequential persistent-runtime and Fiber isolation tests. +TalkingBytes 2.1 implementation work is complete. -Then do the gRPC wire-error correction early because the current exception-class response metadata is a concrete boundary leak. +1. Keep this release-candidate commit frozen. +2. Require the complete Security & Standards workflow to remain green on this exact head. +3. Tag/release only that verified head. +4. Return to Foundation 3 runtime point 26.9 and consume the released TalkingBytes 2.1 APIs. +5. Remove the duplicated Foundation protocol-composition logic listed in Section 20 while preserving Foundation-owned profile lookup, DI lifetime, path/secret policy, replay storage and worker supervision. -Do not modify Foundation until TalkingBytes exposes the clean lower-layer APIs. Foundation should consume the released result afterward. +The optional benchmark follow-ups listed in Sections 11 and 15 are performance-research items, not 2.1 release blockers. From c521a9a9b5ee2050e89be101bc17d91ecd9e7b00 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 20:18:56 +0600 Subject: [PATCH 50/51] docs: finalize TalkingBytes 2.1 plan --- docs/grpc/inbound-outbound.rst | 15 ++++ ...1-foundation-integration-hardening-plan.md | 69 +++++++++---------- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/docs/grpc/inbound-outbound.rst b/docs/grpc/inbound-outbound.rst index b753103..558d9d6 100644 --- a/docs/grpc/inbound-outbound.rst +++ b/docs/grpc/inbound-outbound.rst @@ -125,6 +125,21 @@ 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 ------------------------ diff --git a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md index 3b8bb28..1a9d7d9 100644 --- a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md +++ b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md @@ -470,8 +470,8 @@ native/server runtime - [x] Do not add socket/process supervision. - [x] Do not require Foundation or Omnibus. - [x] Keep ext-grpc and grpc/grpc cold until selected. -- [ ] Document exact inbound streaming modes actually implemented. -- [ ] Keep inbound streaming incremental and bounded. +- [x] Document the exact inbound 2.1 scope: host-controlled unary/request-response exchanges only; no server-side inbound streaming contract is exposed. +- [x] Keep streaming incremental and bounded: outbound/native streaming processes iterables/callbacks incrementally; the 2.1 inbound exchange boundary does not buffer or expose a streaming mode. ### Security correction @@ -624,7 +624,7 @@ Improve throughput without threads, forks or a new async framework. - [x] Move pool durations to monotonic Clock. - [x] Prove rolling-window slot refill against mixed fast/slow local endpoints with deterministic integration timing. - [x] Track repeated-run object/state cleanup with soak tests; active cURL-handle cleanup is covered by cancellation integration tests. -- [ ] Optional follow-up: record a historical 2.0 chunked-versus-2.1 rolling I/O benchmark outside the CPU microbenchmark suite. This is non-gating for 2.1. +- Deferred performance research: record a historical 2.0 chunked-versus-2.1 rolling I/O comparison outside the CPU microbenchmark suite if a future performance investigation needs it. ### Non-goal @@ -722,7 +722,7 @@ Foundation owns bridge attribution. - [x] verification; - [x] verification plus replay claim; - [x] duplicate rejection; -- [ ] Optional follow-up: dedicated retry/cancellation micro-overhead benchmark. Non-gating for 2.1 because cancellation/retry behavior is covered deterministically by tests. +- Deferred performance research: dedicated webhook retry/cancellation micro-overhead measurement; behavior is already covered deterministically by tests. ### gRPC @@ -731,16 +731,16 @@ Foundation owns bridge attribution. - [x] host accepted-exchange bridge; - [x] retry success-path/decision overhead; - [x] generated adapter; -- [ ] Optional follow-up: native streaming/cancellation micro-overhead benchmark. Non-gating for 2.1 because streaming remains incremental and cancellation/finalization are covered by deterministic tests. +- Deferred performance research: native streaming/cancellation micro-overhead measurement; streaming remains incremental and cancellation/finalization are already covered deterministically. ### Email - [x] message preparation; - [x] null/fake send; - [x] parser; -- [ ] Optional follow-up: disk-backed spool-receive microbenchmark. Keep disk I/O outside the CPU suite. +- Deferred performance research: disk-backed spool-receive measurement; keep disk I/O outside the CPU suite. - [x] deterministic mailbox adapter; -- [ ] Optional follow-up: sendmail process-control microbenchmark. Keep child-process I/O outside the CPU suite. +- Deferred performance research: sendmail process-control measurement; keep child-process I/O outside the CPU suite. - [x] 1/10/25 MB streaming payload paths. ### Rules @@ -959,33 +959,33 @@ Non-gating follow-ups are intentionally kept separate from the release gate: his The pre-final implementation head feb0854a2659042660e295cf8e4ff1f6cfe40cf1 passed the complete release workflow, including PHP 8.4/8.5, prefer-lowest/prefer-stable QA, static analysis, native benchmarks, clean install, Mailpit integration, optional-capability coldness, and warning-free Sphinx documentation. -This plan-reconciliation commit is the final release candidate. Batch 10 is complete only if the same complete workflow remains green on this exact commit; no further implementation or plan edits should be made before release/tagging. +The final plan/documentation finalization commit is the release candidate. Batch 10 is complete when the complete workflow is green on that exact head; any subsequent code or documentation change must become a new candidate and repeat the gate. --- ## 20. Foundation 3 Handoff -After TalkingBytes 2.1 is released: - -- [ ] Foundation raises its communication floor to ^2.1 only when the released APIs are consumed. -- [ ] Foundation keeps named application profile lookup. -- [ ] Foundation keeps path/secret resolution and production policy. -- [ ] Foundation keeps DI lifetime selection. -- [ ] Foundation keeps CacheLayerWebhookReplayStore. -- [ ] Foundation keeps gRPC handler service lookup. -- [ ] Foundation keeps ProcessRunner for console/scheduler/application subprocesses. -- [ ] Foundation maps worker heartbeat/stop/release replacement into TalkingBytes cancellation. -- [ ] Foundation removes duplicated HTTP auth/cookie/retry/rate-limit/circuit/idempotency composition when TalkingBytes native composition is available. -- [ ] Foundation removes duplicated gRPC retry/native/generated composition when TalkingBytes owns it. -- [ ] Foundation removes duplicated webhook retry/signing composition where TalkingBytes can consume resolved values directly. -- [ ] Foundation removes duplicated email transport/fallback/retry/rate-limit/DKIM composition where TalkingBytes factories can consume resolved config. -- [ ] Foundation replaces manual EmailLimits construction with TalkingBytes parsing. -- [ ] Foundation keeps default From and notification/template routing as application policy. -- [ ] Foundation keeps HTTP clients scoped when mutable state is attached. -- [ ] Foundation routes inbound gRPC through the new host-controlled boundary. -- [ ] Foundation proves communication secrets are absent from generated metadata, cache keys and logs. -- [ ] Foundation adds direct-TalkingBytes-versus-Foundation bridge benchmark attribution. -- [ ] Foundation closes runtime plan point 26.9 only on exact-head green CI. +After TalkingBytes 2.1 is released, the following are **Foundation 3 point 26.9 handoff instructions**, not unfinished TalkingBytes tasks: + +- raise the Foundation communication floor to ^2.1 only when the released APIs are consumed; +- keep named application profile lookup in Foundation; +- keep path/secret resolution and production policy in Foundation; +- keep DI lifetime selection in Foundation; +- keep CacheLayerWebhookReplayStore in Foundation; +- keep gRPC handler service lookup in Foundation; +- keep ProcessRunner for console/scheduler/application subprocesses in Foundation; +- map Foundation worker heartbeat/stop/release replacement into TalkingBytes cancellation; +- remove duplicated HTTP auth/cookie/retry/rate-limit/circuit/idempotency composition when consuming TalkingBytes native composition; +- remove duplicated gRPC retry/native/generated composition when consuming TalkingBytes ownership; +- remove duplicated webhook retry/signing composition where TalkingBytes consumes resolved values directly; +- remove duplicated email transport/fallback/retry/rate-limit/DKIM composition where TalkingBytes factories consume resolved config; +- replace manual EmailLimits construction with TalkingBytes parsing; +- keep default From and notification/template routing as Foundation application policy; +- keep HTTP clients scoped when mutable state is attached; +- route inbound gRPC through the new host-controlled request/response boundary; +- prove communication secrets are absent from generated metadata, cache keys and logs; +- add direct-TalkingBytes-versus-Foundation bridge benchmark attribution in Foundation; +- close Foundation runtime plan point 26.9 only on Foundation's own exact-head green CI. ### Expected Foundation simplification targets @@ -1026,7 +1026,7 @@ TalkingBytes 2.1 is complete only when: - [x] PHPForge QA/static/security gates pass on supported PHP/dependency matrices; - [x] documentation builds warning-free; - [x] release metadata/examples match final APIs; -- [ ] release/tag action: tag only this exact final commit after its complete matrix is green. +- Release operation (outside implementation completion): tag/release only the final verified exact head; tagging itself is not an open TalkingBytes implementation task. --- @@ -1052,10 +1052,9 @@ Do not use 2.1 to add: TalkingBytes 2.1 implementation work is complete. -1. Keep this release-candidate commit frozen. -2. Require the complete Security & Standards workflow to remain green on this exact head. -3. Tag/release only that verified head. -4. Return to Foundation 3 runtime point 26.9 and consume the released TalkingBytes 2.1 APIs. -5. Remove the duplicated Foundation protocol-composition logic listed in Section 20 while preserving Foundation-owned profile lookup, DI lifetime, path/secret policy, replay storage and worker supervision. +1. Keep the final verified release-candidate head frozen. +2. Tag/release only that verified head. +3. Return to Foundation 3 runtime point 26.9 and consume the released TalkingBytes 2.1 APIs. +4. Remove the duplicated Foundation protocol-composition logic listed in Section 20 while preserving Foundation-owned profile lookup, DI lifetime, path/secret policy, replay storage and worker supervision. The optional benchmark follow-ups listed in Sections 11 and 15 are performance-research items, not 2.1 release blockers. From 67ba1d3b75ea0484df320425d207e2ef2b98c19d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 22 Sep 2026 22:51:44 +0600 Subject: [PATCH 51/51] cleanup --- .gitignore | 1 + benchmarks/WebhookBench.php | 10 +- ...1-foundation-integration-hardening-plan.md | 1060 ----- docs/webhook/end-to-end.rst | 2 +- docs/webhook/replay.rst | 5 +- docs/webhook/sender.rst | 29 + docs/webhook/verifier-receiver.rst | 15 +- plan.md | 3583 ----------------- src/Email/Parser/CharsetDecoder.php | 8 +- src/Webhook/Model/WebhookSignature.php | 18 +- .../Signing/WebhookSignatureParser.php | 8 +- src/Webhook/Testing/WebhookTestFactory.php | 2 +- src/Webhook/WebhookReceiver.php | 28 +- src/Webhook/WebhookSender.php | 5 +- src/Webhook/WebhookVerifier.php | 10 +- tests/CharsetDecoderTest.php | 34 + tests/WebhookEventRedactionTest.php | 8 +- tests/WebhookReceiverTest.php | 50 +- 18 files changed, 190 insertions(+), 4686 deletions(-) delete mode 100644 docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md delete mode 100644 plan.md 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/WebhookBench.php b/benchmarks/WebhookBench.php index 642efe2..09dc581 100644 --- a/benchmarks/WebhookBench.php +++ b/benchmarks/WebhookBench.php @@ -47,7 +47,7 @@ public function setUp(): void ], JSON_THROW_ON_ERROR); $this->timestamp = 1_720_000_000; $this->signature = new WebhookSignature('secret'); - $this->signatureHeader = $this->signature->buildHeader($this->payload, $this->timestamp); + $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); @@ -65,7 +65,7 @@ public function benchDuplicateRejection(): void #[Revs(1000)] public function benchParseSignature(): void { - $this->parser->parse($this->signatureHeader); + $this->parser->parse($this->signatureHeader, version: 'v2'); } #[Iterations(5)] @@ -80,14 +80,14 @@ public function benchReplayClaim(): void #[Revs(1000)] public function benchSignWebhook(): void { - $this->signature->buildHeader($this->payload, $this->timestamp); + $this->signature->buildHeader($this->payload, $this->timestamp, 'invoice.paid', 'delivery-bench'); } #[Iterations(5)] #[Revs(1000)] public function benchVerificationAndReplayClaim(): 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'); $this->replayCounter++; $this->replayStore->claim('verify', 'delivery-' . $this->replayCounter, 60); } @@ -96,6 +96,6 @@ public function benchVerificationAndReplayClaim(): void #[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/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md b/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md deleted file mode 100644 index 1a9d7d9..0000000 --- a/docs/plans/talkingbytes-2.1-foundation-integration-hardening-plan.md +++ /dev/null @@ -1,1060 +0,0 @@ -# TalkingBytes 2.1 — Foundation 3 Integration Hardening & Runtime Ownership Plan - -## Status - -Recommended target release: **TalkingBytes 2.1.x** - -Baseline: - -- working branch: talkingbytes-2.1/foundation-integration-hardening -- implementation baseline branch: main -- released baseline: 2.0.0 -- baseline commit: 86d0e9dde8124ddeacea8ba7f81911af584b879b -- implementation head through Batch 8: 45c7dd11d1c77c3e4ff1b68ab4a1999ed5f6761f -- pre-final implementation/release-gate head: feb0854a2659042660e295cf8e4ff1f6cfe40cf1 -- final release candidate: this plan-reconciliation commit, subject to its exact-head CI result -- primary consumer: Foundation 3 runtime plan point 26.9 -- plan state: **RELEASE CANDIDATE — BATCHES 1–10 COMPLETE / FOUNDATION 26.9 HANDOFF NEXT** - -Batch progress: - -- [x] Batch 1 — runtime-state, clock and cancellation foundation -- [x] Batch 2 — gRPC security and host boundary -- [x] Batch 3 — email runtime and sendmail process hardening -- [x] Batch 4 — native protocol composition builders -- [x] Batch 5 — webhook replay acceptance -- [x] Batch 6 — HTTP rolling multi scheduler -- [x] Batch 7 — gRPC generated adapter determinism -- [x] Batch 8 — observability and data minimization -- [x] Batch 9 — optional cold graphs, docs and benchmarks -- [x] Batch 10 — exact-head release gate - -TalkingBytes 2.0 already established the intended protocol architecture. The 2.1 release should harden that architecture for persistent workers, Fibers, framework integration and high-throughput use while moving protocol composition out of Foundation where it currently leaks upward. - -The default release remains 2.1 because the required work can be implemented additively. Promote the release to the next major only if implementation proves that a public removal, incompatible constructor/signature change, or incompatible configuration semantic is genuinely required. Do not create a major release merely to permit cleanup that can remain additive. - ---- - -## 1. Release Policy - -### 1.1 Compatibility - -Keep 2.1 additive and minor-release compatible wherever practical. - -Priority order: - -1. correctness -2. security -3. runtime isolation -4. protocol ownership -5. performance -6. scalability -7. API clarity -8. compatibility - -If a correctness or security defect cannot be fixed compatibly, make the smallest necessary correction, document it, and use the major-version decision gate in Section 18. - -### 1.2 Runtime floor - -Keep PHP >= 8.4. - -Keep infocyph/phpforge dev-main@dev in require-dev. - -### 1.3 Dependency policy - -TalkingBytes must remain framework-agnostic and lightweight. - -Do not add Foundation, InterMix, CacheLayer, DBLayer, Omnibus, Pathwise or another Infocyph runtime library merely to simplify host integration. - -Optional integrations remain behind native contracts and adapters. - -### 1.4 Extension policy - -Do not make pcntl or posix required extensions. - -- posix may be used opportunistically for safer Unix child-process group termination where available. -- pcntl must not be used implicitly by normal HTTP, email, webhook or gRPC object graphs. -- do not add fork-based protocol concurrency. -- do not install process-global signal handlers from ordinary protocol clients/transports. -- if a standalone CLI signal-to-cancellation adapter is eventually added, it must be explicit opt-in, restore previous handlers, remain optional and be independently tested. -- Foundation keeps ownership of its worker/supervisor signal lifecycle. - ---- - -## 2. Ownership Boundary - -### TalkingBytes owns - -- outbound HTTP request preparation and transport; -- redirect processing, HTTP streaming, concurrent request mechanics, retry and transport security; -- HTTP authentication, signing, idempotency and cookie mechanics; -- protocol-native client/factory composition from already-resolved values; -- inbound and outbound email protocol mechanics; -- SMTP, sendmail, PHP mail, spool and logging transports; -- IMAP and POP3 mailbox behavior; -- MIME parsing, transfer decoding, charset decoding and attachment extraction; -- DKIM, authentication-result parsing and bounce classification; -- webhook signing, verification, timestamp validation, retry semantics and replay-store contract; -- gRPC request/response models; -- gRPC metadata, deadlines, status mapping, retry and stream mechanics; -- generated/native gRPC adapters; -- host-controllable inbound gRPC exchange adaptation; -- protocol-level cancellation checks where an operation can wait, retry, stream or poll; -- protocol fakes, assertion helpers, events and native benchmarks. - -### Foundation owns - -- named application profile lookup and default profile selection; -- capability selection and dependency activation; -- DI lifetime selection; -- application secret resolution and production policy; -- application path resolution; -- application-level configuration source mapping; -- CacheLayer-backed webhook replay-store implementation; -- worker heartbeat, stop and release-generation lifecycle; -- process supervision for Foundation console/scheduler/application commands; -- application service/handler lookup and DI mapping; -- application logging/audit policy; -- application notification/template mapping; -- direct-versus-TalkingBytes bridge benchmarks. - -### Explicit non-ownership - -TalkingBytes must not become: - -- a Foundation-specific package; -- an HTTP application framework/server; -- a cache/database abstraction; -- a queue or worker supervisor; -- a generic application process manager; -- a home-grown gRPC wire stack; -- a service container; -- a named application-profile repository. - -### Foundation code that should remain in Foundation - -Do not move these merely to make Foundation smaller: - -- CacheLayerWebhookReplayStore; -- production TLS policy and deployment-specific security policy; -- change-me/default-secret rejection; -- named profile lookup; -- application path expansion; -- DI service-to-gRPC-handler resolution; -- auth notification mapping/templates; -- notification recipient routing; -- Foundation ProcessRunner used by console, module and scheduler execution. - -TalkingBytes should only absorb the lower-level protocol composition currently duplicated around those policies. - ---- - -## 3. Verified Baseline Findings - -### 3.1 Process-global event state remains in runtime paths - -CommunicationEventBus stores a static dispatcher. - -Production runtime paths still call it directly, including: - -- Email/Receiver/SpoolEmailReceiver; -- Email/Mailbox/SocketMailboxRuntime; -- Email/Parser/BounceParser. - -The static bus is already documented as compatibility-only, but these runtime paths still make global state part of normal execution. - -### 3.2 HTTP graph is immutable, selected collaborators are mutable - -HttpClient is fluent/immutable, but these collaborators intentionally retain state: - -- CookieJar; -- CircuitBreaker; -- RateLimiter; -- fake/spy transports. - -Host lifetime rules must therefore be explicit and tested. - -### 3.3 Webhook replay abstraction is correctly host-neutral - -WebhookReplayStore::claim(namespace, deliveryId, ttlSeconds) expresses the correct lower-layer requirement: - -- atomic first claim; -- bounded TTL; -- duplicate detection. - -Foundation should keep its CacheLayer implementation. TalkingBytes should harden the contract and tests, not acquire a CacheLayer dependency. - -### 3.4 Inbound gRPC currently stops at dispatch - -GrpcInboundDispatcher maps normalized inbound requests to handlers but does not provide a host-facing accepted-call/source/exchange boundary that a Foundation worker can drive one call at a time. - -### 3.5 Inbound gRPC currently leaks implementation detail into response metadata - -When an inbound handler throws, GrpcInboundDispatcher returns a GrpcInboundResponse containing the exception class in response metadata. - -That metadata can cross the protocol boundary. Internal exception classes must not be returned to remote callers by default. - -The exception class may be retained in local observability where policy permits, but not in the wire response. - -### 3.6 Observability redaction is not uniformly strict - -Some protocol events still include raw result errors or exception messages. - -Email/spool events can also expose operational paths or message subjects. These are not authentication secrets, but they may be sensitive application/PII data and should not be default observability fields when a stable identifier/category is sufficient. - -### 3.7 Optional capability coldness needs release evidence - -Native gRPC and multiple mail-related capabilities are optional by design. - -Unrelated protocol use must not eagerly require or initialize optional capabilities. - -### 3.8 Timing is only partially monotonic - -Core/Support/Clock already supports a monotonic source using hrtime, and gRPC retry uses it. - -Other runtime paths still use microtime(true) or time() for durations/deadlines, including: - -- HTTP transport/pool durations; -- webhook delivery duration; -- gRPC client/transport/inbound event durations; -- SMTP command timing; -- sendmail timeout handling; -- IMAP/POP3 deadlines; -- mailbox watch loops; -- spool receive events; -- Emailer events. - -Elapsed time and deadlines should use the monotonic clock. Wall time should remain only where protocol semantics require real timestamps, such as webhook signature timestamps. - -### 3.9 Waiting and cancellation are inconsistent - -TalkingBytes already has Sleeper and mailbox watch callbacks such as shouldStop, but waiting behavior is fragmented: - -- retry paths sleep through Sleeper; -- sendmail uses raw usleep loops; -- POP3 watch uses time plus raw usleep; -- IMAP watch uses stream_select plus raw usleep fallback; -- generated/native gRPC stream loops do not expose a uniform cancellation check; -- HTTP multi does not accept a cooperative cancellation signal. - -Persistent hosts need one small lower-layer cancellation contract so Foundation can adapt its heartbeat/stop/release policy without TalkingBytes depending on Foundation. - -### 3.10 Sendmail process supervision is weaker than Foundation process execution - -SendmailTransport owns a private proc_open loop and terminates the direct child with proc_terminate. - -Foundation has stronger generic process handling with timeout/cancellation and optional POSIX process-group termination. The full Foundation ProcessRunner must not move into TalkingBytes because Foundation uses it for console/scheduler/application process execution. - -TalkingBytes should instead own a narrow sendmail child-process supervisor with: - -- array command/no shell; -- bounded stdout/stderr capture; -- monotonic timeout; -- cooperative cancellation; -- graceful then forced termination; -- optional POSIX process-group termination when safely available; -- portable direct-child fallback. - -### 3.11 Foundation duplicates TalkingBytes protocol composition - -Foundation CommunicationProfiles currently composes TalkingBytes behavior for: - -- HTTP auth; -- CookieJar; -- HTTP retry; -- RateLimiter; -- CircuitBreaker; -- idempotency; -- gRPC retry; -- generated/native gRPC client selection; -- webhook signing and retry. - -Foundation EmailProfiles currently composes: - -- transport-driver selection; -- fallbacks; -- retry; -- rate limiting; -- DKIM; -- sender transport config. - -Foundation NotificationGraphFactory also reconstructs EmailLimits from arrays. - -These are protocol-native composition concerns once paths/secrets/default profile names have already been resolved. - -### 3.12 Generated gRPC stub adaptation uses exception-driven signature probing - -GeneratedStubGrpcInvoker opens streaming calls by attempting one invocation signature and catching ArgumentCountError or TypeError before trying another. - -Catching TypeError around the method invocation can accidentally treat a real TypeError from inside a user/generated stub as an invocation-shape mismatch and may duplicate side effects. - -Resolve/validate the call shape deterministically instead of probing by executing and catching broad TypeError. - -### 3.13 HTTP multi concurrency is chunked, not a rolling window - -CurlMultiTransport currently array-chunks requests by max concurrency, waits for the full chunk to complete, then schedules the next chunk. - -This causes avoidable head-of-line blocking when one slow request holds back scheduling even though another slot has become free. - -A rolling-window scheduler can improve throughput and latency without adding threads or fork-based concurrency. - -### 3.14 Raw global error-handler usage requires an isolation audit - -Several paths temporarily call set_error_handler for warning capture/suppression. - -Most restore it in finally and do not deliberately suspend a Fiber while installed, so this is not automatically a defect. Still, the release should prove that no code path can yield/call arbitrary user code while a temporary process-global handler is installed. - -Prefer expression-local/native error handling where practical. - ---- - -## 4. Workstream 1 — P0 Runtime Global-State Isolation - -### Goal - -Normal TalkingBytes object graphs must not depend on process-global mutable state. - -### Tasks - -- [x] Propagate optional EventDispatcher dependencies through email sender/receiver/mailbox/parser factories where events are emitted. -- [x] Convert SpoolEmailReceiver lifecycle events to injected dispatch. -- [x] Convert mailbox command events away from direct CommunicationEventBus use. -- [x] Convert BounceParser event emission away from direct CommunicationEventBus use. -- [x] Audit every production src reference to CommunicationEventBus. -- [x] Keep CommunicationEventBus only as a compatibility facade. -- [x] Ensure new runtime code never requires the static bus. -- [x] Keep dispatch best-effort: listener failures must not alter protocol results or cleanup. -- [x] Audit temporary set_error_handler regions. -- [x] Ensure no temporary global error handler spans arbitrary user callbacks, Fiber suspension, event dispatch or long-lived loops. -- [x] Add sequential persistent-runtime tests proving event listeners and temporary runtime state do not leak. -- [x] Add Fiber-interleaving tests for relevant stateless/object-scoped paths. -- [x] Update events documentation to make injection authoritative. - -### Acceptance - -A normal graph created through public constructors/factories must work correctly with CommunicationEventBus untouched. - ---- - -## 5. Workstream 2 — P0 Monotonic Time, Cancellation and Interruptible Waiting - -### Goal - -Long-running/retrying operations become host-controllable without TalkingBytes owning the host lifecycle. - -### Direction - -Introduce the smallest useful cancellation abstraction. Exact naming may change. - -Conceptually: - -- CancellationSignal::isRequested(): bool; -- a never-cancelled implementation; -- optional adapter from a callable; -- no dependency on Foundation; -- no global registry. - -Do not create a general task framework. - -### Tasks - -- [x] Standardize elapsed durations and internal deadlines on Core/Support/Clock::monotonic(). -- [x] Keep Clock::timestamp()/wall time only for protocol timestamps that require real time. -- [x] Extend waiting support so retry/backoff sleeps can be interrupted in bounded slices when a cancellation signal is supplied. -- [x] Keep the current simple Sleeper path cheap when no cancellation is supplied. -- [x] Allow RetryExecutor to stop before the next attempt when cancelled. -- [x] Allow HTTP retry and gRPC retry to stop before sleeping/retrying when cancelled. -- [x] Allow WebhookSender retry to stop cooperatively. -- [x] Allow mailbox watch loops to consume the same cancellation abstraction while retaining callable compatibility where practical. -- [x] Allow generated/native gRPC streaming loops to check cancellation between messages/writes/reads where the native API permits. -- [x] Allow the inbound gRPC accepted-call bridge to stop before accepting the next exchange. -- [x] Allow CurlMultiTransport to stop scheduling and terminate/close active work safely when host cancellation is requested, if libcurl semantics permit deterministic cleanup. -- [x] Add deterministic fake-clock/fake-sleeper/cancellation tests. -- [x] Verify cancellation never skips required resource cleanup. - -### Foundation handoff - -Foundation adapts heartbeat loss, stop token and release-generation replacement into the TalkingBytes cancellation boundary. TalkingBytes does not know those Foundation concepts. - ---- - -## 6. Workstream 3 — P0 Mutable-State Lifetime Contracts - -### Required classifications - -| Component | State model | Host expectation | -| --- | --- | --- | -| HttpClientConfig | immutable configuration | reusable | -| HttpClient without mutable collaborators | immutable graph | reusable when policy allows | -| CookieJar | mutable session state | execution/session scoped | -| CircuitBreaker | mutable resilience state | explicit shared/profile scope only | -| RateLimiter | mutable token state | explicit shared/profile scope only | -| WebhookVerifier | immutable secret/policy graph | reusable if secret lifecycle permits | -| WebhookReceiver | immutable graph around replay store | replay-store lifetime dependent | -| GrpcClient | immutable graph over invoker | invoker lifetime dependent | -| GrpcInboundDispatcher | immutable handler graph | reusable if handlers are safe | -| Emailer | immutable graph over transport | transport lifetime dependent | -| SMTP transport | per-send connection today | reusable graph if collaborators are safe | -| mailbox/socket transports | connection/session state | execution/worker owned | -| generated gRPC/native invokers | native channel/stub lifetime dependent | host/profile scoped deliberately | -| fakes/spies | mutable test state | test scoped | - -### Tasks - -- [x] Publish the matrix in architecture/runtime docs. -- [x] Prove fluent operations do not mutate previous instances. -- [x] Prove CookieJar isolation. -- [x] Prove CircuitBreaker isolation. -- [x] Prove RateLimiter isolation. -- [x] Prove mailbox connections are not shared accidentally across scoped graphs. -- [x] Document native gRPC stub/channel lifetime expectations. -- [x] Add sequential/Fiber tests around mutable collaborators. -- [x] Do not introduce global resilience or native-client registries. -- [x] Ensure fake/spy state has deterministic new-instance/reset behavior. - ---- - -## 7. Workstream 4 — P0 Webhook Replay Hardening - -### Goal - -Replay protection remains protocol-owned and storage-provider-neutral. - -### Tasks - -- [x] Keep WebhookReplayStore minimal. -- [x] Document that production claim must be atomic across competing processes. -- [x] Document backend errors as fail-closed. -- [x] Add a contention contract test where only one contender wins. -- [x] Add a throwing-store test proving replay protection is not bypassed. -- [x] Preserve strict positive TTL validation. -- [x] Preserve bounded namespace/delivery-ID validation. -- [x] Preserve signature/timestamp verification before replay claim. -- [x] Preserve replay claim before a verified event is returned. -- [x] Mark InMemoryWebhookReplayStore clearly as single-process/test/local-use unless its guarantees are sufficient for the documented deployment. -- [x] Ensure replay observability never exposes raw secret/signature/body. -- [x] Do not add CacheLayer. - -### Foundation handoff - -Foundation keeps CacheLayerWebhookReplayStore and its Foundation security cache-key domain. - ---- - -## 8. Workstream 5 — P0 Host-Controlled Inbound gRPC Runtime Bridge - -### Goal - -Foundation or another host can run inbound gRPC through its own lifecycle without recreating TalkingBytes protocol adaptation. - -### Target flow - -native/server runtime -→ TalkingBytes inbound source/adapter -→ accepted exchange -→ GrpcInboundRequest -→ GrpcInboundDispatcher -→ GrpcInboundResponse -→ TalkingBytes exchange completion - -### Required characteristics - -- [x] Add a small contract for accepting/obtaining one inbound gRPC exchange. -- [x] Accepted exchange exposes normalized GrpcInboundRequest. -- [x] TalkingBytes maps GrpcInboundResponse/status/metadata back to the native exchange. -- [x] Provide a one-cycle or otherwise host-controllable execution API. -- [x] Accept cancellation between calls and, where supported, during streams. -- [x] Do not hide an uncontrolled infinite process loop. -- [x] Preserve method normalization, metadata, deadline and status mapping. -- [x] Add fake inbound source/exchange utilities. -- [x] Do not add socket/process supervision. -- [x] Do not require Foundation or Omnibus. -- [x] Keep ext-grpc and grpc/grpc cold until selected. -- [x] Document the exact inbound 2.1 scope: host-controlled unary/request-response exchanges only; no server-side inbound streaming contract is exposed. -- [x] Keep streaming incremental and bounded: outbound/native streaming processes iterables/callbacks incrementally; the 2.1 inbound exchange boundary does not buffer or expose a streaming mode. - -### Security correction - -- [x] Remove handler exception class from GrpcInboundResponse wire metadata. -- [x] Return stable INTERNAL status/message only. -- [x] Keep richer exception classification only in local events/logging when safe. -- [x] Add a test proving remote responses do not reveal exception class, file path, trace or raw exception message. - ---- - -## 9. Workstream 6 — P0/P1 Persistent-Runtime Email and Sendmail Process Hardening - -### Email runtime tasks - -- [x] Propagate injected EventDispatcher objects through EmailSenderFactory, EmailReceiverFactory and EmailMailboxFactory. -- [x] Keep Emailer transport composition native to TalkingBytes. -- [x] Keep SMTP/sendmail/mail/spool behavior native. -- [x] Keep IMAP/POP3 behavior native. -- [x] Keep MIME/parsing/DKIM/bounce behavior native. -- [x] Define mailbox connection ownership and deterministic close/logout behavior. -- [x] Ensure failed sessions cannot poison newly constructed instances. -- [x] Preserve bounded line/message/attachment/parser limits. -- [x] Preserve spool locking, quarantine and safe move semantics. -- [x] Replace wall-clock logical deadlines with monotonic clock. -- [x] Replace raw watch-loop sleeps with injectable waiting where useful. -- [x] Keep IMAP IDLE cancellation responsive. -- [x] Keep POP3 polling cancellation responsive. -- [x] Add persistent-worker and cancellation tests. - -### Sendmail subprocess tasks - -- [x] Keep command execution as an argument array and bypass the shell. -- [x] Extract the private process loop into a narrow internal sendmail child-process helper if that reduces duplication/complexity. -- [x] Use monotonic timeout. -- [x] Add cooperative cancellation. -- [x] Keep stdout/stderr capture bounded. -- [x] Terminate gracefully, wait a bounded grace period, then force termination. -- [x] When posix_setpgid/posix_getpgid/posix_kill are available and safe, place the child in its own process group and terminate the group so descendants are not orphaned. -- [x] Fall back to direct proc_terminate when POSIX group control is unavailable. -- [x] Do not require ext-posix. -- [x] Do not require ext-pcntl. -- [x] Do not import Foundation ProcessRunner or make TalkingBytes a generic process package. -- [x] Add tests for timeout, cancellation, forced termination and cleanup. -- [x] Add optional Unix process-group coverage where CI supports it. -- [x] Verify Windows/non-POSIX fallback behavior remains valid. - -### pcntl policy - -- [x] Do not register SIGINT/SIGTERM handlers inside SendmailTransport, SMTP, HTTP, webhook or gRPC normal paths. -- [x] Foundation continues translating its worker signals into cancellation. -- [x] No standalone PcntlSignalCancellation adapter is justified for 2.1; keep PCNTL out of runtime code. -- [x] Any future explicit PCNTL adapter remains a later opt-in design and is not part of the 2.1 dependency path. - ---- - -## 10. Workstream 7 — P1 Native Composition Builders to Shrink Foundation - -### Goal - -Foundation should select named profiles and resolve application values. TalkingBytes should turn resolved protocol configuration into protocol objects. - -Do not introduce Foundation-specific configuration names or a large profile framework. - -Prefer extending existing factories/facades before adding many new abstractions. - -### HTTP composition - -Move the mechanics currently in Foundation CommunicationProfiles::decorateHttp into a TalkingBytes-native builder/factory: - -- [x] auth driver composition; -- [x] CookieJar opt-in; -- [x] retry policy composition; -- [x] RateLimiter composition; -- [x] CircuitBreaker composition; -- [x] idempotency middleware composition. - -Foundation should still: - -- choose the named HTTP profile; -- resolve secrets; -- enforce production TLS policy; -- decide DI lifetime. - -### gRPC composition - -- [x] Add a direct TalkingBytes convenience path for generated stubs so Foundation does not construct GeneratedStubGrpcInvoker itself unless it needs customization. -- [x] Centralize native/generated/streaming client composition in TalkingBytes. -- [x] Centralize gRPC retry-profile application in TalkingBytes. -- [x] Allow EventDispatcher injection through usingNative/usingNativeStreaming/generated-stub paths. -- [x] Keep service/handler lookup in Foundation. - -### Webhook composition - -- [x] Keep signing, verifier/receiver creation and retry-profile mechanics in TalkingBytes. -- [x] Allow a resolved outbound/inbound config array or small typed config to be applied without Foundation recreating protocol rules. -- [x] Keep secret source resolution and production-secret policy in Foundation. -- [x] Keep replay-store implementation in Foundation. - -### Email composition - -Expand native email factory capability so Foundation no longer has to own protocol transport/decorator mechanics: - -- [x] transport driver creation from resolved transport config; -- [x] fallback transport composition; -- [x] retry policy composition; -- [x] rate-limit composition; -- [x] DKIM config/application after path/secret resolution; -- [x] parser-limit parsing. - -Specific easy win: - -- [x] add EmailLimits::fromArray() using TalkingBytes-native strict config parsing so Foundation NotificationGraphFactory does not duplicate EmailLimits construction. - -Foundation should still: - -- choose named sender/transport/mailbox/receiver profiles; -- resolve relative application paths; -- resolve private keys/secrets from application configuration; -- apply default From policy; -- own notification/template routing. - -### Acceptance - -After the Foundation follow-up: - -- CommunicationProfiles should mostly perform profile lookup, host policy and delegation. -- EmailProfiles should mostly perform profile lookup/path resolution and delegation. -- no protocol retry/auth/cookie/DKIM/fallback algorithm should be recreated in Foundation. - ---- - -## 11. Workstream 8 — P1 HTTP Concurrent Scheduler and Runtime Control - -### Goal - -Improve throughput without threads, forks or a new async framework. - -### Tasks - -- [x] Replace array_chunk batch scheduling with a rolling cURL multi window up to maxConcurrency. -- [x] As soon as one handle completes, schedule the next pending request. -- [x] Preserve result ordering by original keys. -- [x] Preserve bounded concurrency. -- [x] Preserve cleanup on every failure/listener/cancellation path. -- [x] Preserve current truthful stopSchedulingOnFailure semantics. -- [x] When a failure is observed and stop-scheduling is enabled, stop adding new requests immediately. -- [x] Do not claim active-request fail-fast cancellation unless it is actually implemented. -- [x] If cancellation is supplied, close/remove active handles safely and return deterministic cancelled results/metadata. -- [x] Keep manual redirect security behavior; do not re-enable unsafe automatic redirect handling in CurlMultiTransport. -- [x] Move pool durations to monotonic Clock. -- [x] Prove rolling-window slot refill against mixed fast/slow local endpoints with deterministic integration timing. -- [x] Track repeated-run object/state cleanup with soak tests; active cURL-handle cleanup is covered by cancellation integration tests. -- Deferred performance research: record a historical 2.0 chunked-versus-2.1 rolling I/O comparison outside the CPU microbenchmark suite if a future performance investigation needs it. - -### Non-goal - -Do not add pcntl_fork, pthreads, parallel, ReactPHP or Amp merely for this scheduler. - ---- - -## 12. Workstream 9 — P1 gRPC Native Adapter Determinism and Streaming Control - -### Tasks - -- [x] Remove exception-driven TypeError probing for generated streaming call shape. -- [x] Resolve the supported generated-stub call shape before executing the real call. -- [x] Prefer explicit adapter metadata/callable strategy or bounded reflection cached at adapter construction. -- [x] Never retry an invocation merely because a TypeError was thrown from inside the invoked method. -- [x] Validate method maps early. -- [x] Keep generated/native package capability checks cold. -- [x] Add cancellation checks between outbound stream writes and inbound reads where possible. -- [x] Preserve incremental streaming; never accumulate full streams. -- [x] Ensure callback exceptions close/finalize native call resources deterministically. -- [x] Add tests proving no duplicate side effect occurs during call-shape resolution. -- [x] Add tests for cancellation, callback failure and final status/trailer handling. - ---- - -## 13. Workstream 10 — P1 Observability, Redaction and Data-Minimization - -### Goal - -Default events/log context must not expose secrets or unnecessary payload/PII. - -### Tasks - -- [x] Never emit raw Authorization credentials. -- [x] Never emit raw bearer/API tokens. -- [x] Never emit cookie values. -- [x] Never emit proxy credentials. -- [x] Never emit webhook secrets/signatures/bodies. -- [x] Never emit SMTP/mailbox passwords or raw auth commands. -- [x] Avoid raw gRPC metadata values unless explicitly classified safe. -- [x] Do not copy raw exception messages blindly into protocol events. -- [x] Prefer stable failure category, exception class where locally appropriate, protocol status/code and bounded sanitized diagnostics. -- [x] Remove exception class from remote gRPC response metadata. -- [x] Review SMTP transcript capture and document it as explicit diagnostic data with clear redaction guarantees. -- [x] Remove or gate spool absolute paths and email subjects from default events when they are not required. -- [x] Keep caller-facing CommunicationResult diagnostics useful; local observability may intentionally be stricter. -- [x] Add sentinel-secret and sentinel-PII tests across HTTP, webhook, gRPC, email and mailbox event payloads. -- [x] Keep hot-path redaction overhead bounded. - ---- - -## 14. Workstream 11 — P1 Optional Capability and Extension Coldness - -### Acceptance matrix - -- [x] HTTP works with unloadable ext-grpc/ext-posix absent and has no PCNTL runtime dependency; generated gRPC packages are not initialized by the HTTP graph. -- [x] Webhook works without native gRPC packages. -- [x] Basic outbound email works without IMAP-specific optional extensions. -- [x] SMTP works without ext-posix and has no PCNTL runtime dependency. -- [x] Sendmail works with portable proc_* fallback when ext-posix is absent. -- [x] POSIX process-group hardening activates only when functions are available. -- [x] IMAP/POP3 optional checks occur only when selected. -- [x] RSA DKIM does not require Sodium. -- [x] Ed25519 DKIM requires Sodium only when that algorithm is selected; hosted builds with compiled-in Sodium are source-confined to the DKIM boundary. -- [x] Native/generated gRPC capability is selected explicitly and unrelated graphs do not probe or initialize it. -- [x] Composer suggest metadata matches actual optional behavior. -- [x] Add ext-posix to suggest only if the released implementation actually uses it as an optional sendmail hardening path. -- [x] Do not add ext-pcntl to suggest unless an explicit public pcntl adapter is shipped. -- [x] Documentation matches Composer metadata. -- [x] Avoid unrelated extension/class probing on protocol hot paths. - ---- - -## 15. Workstream 12 — P1 Native Benchmark and Soak Evidence - -TalkingBytes owns native protocol benchmarks. - -Foundation owns bridge attribution. - -### HTTP - -- [x] immutable client construction; -- [x] resolved-profile/factory construction; -- [x] request preparation; -- [x] fake transport send; -- [x] cookie-enabled send; -- [x] circuit/rate-limit primitive overhead plus resolved retry/resilience composition coverage; -- [x] rolling multi scheduler evidence through deterministic local integration tests; -- [x] cancellation cleanup through active-handle integration tests; -- [x] repeated-run graph/state growth through soak coverage. - -### Webhook - -- [x] signing; -- [x] verification; -- [x] verification plus replay claim; -- [x] duplicate rejection; -- Deferred performance research: dedicated webhook retry/cancellation micro-overhead measurement; behavior is already covered deterministically by tests. - -### gRPC - -- [x] unary dispatch; -- [x] inbound dispatcher; -- [x] host accepted-exchange bridge; -- [x] retry success-path/decision overhead; -- [x] generated adapter; -- Deferred performance research: native streaming/cancellation micro-overhead measurement; streaming remains incremental and cancellation/finalization are already covered deterministically. - -### Email - -- [x] message preparation; -- [x] null/fake send; -- [x] parser; -- Deferred performance research: disk-backed spool-receive measurement; keep disk I/O outside the CPU suite. -- [x] deterministic mailbox adapter; -- Deferred performance research: sendmail process-control measurement; keep child-process I/O outside the CPU suite. -- [x] 1/10/25 MB streaming payload paths. - -### Rules - -- [x] Do not add Foundation as a benchmark dependency. -- [x] Separate CPU microbenchmarks from network/disk/process I/O. -- [x] Record benchmark/runtime metadata and peak memory where meaningful through PHPBench/release evidence. -- [x] Add repeated-run soak checks for state/resource growth. -- [x] Keep protocol-internal elapsed/deadline timing monotonic; PHPBench owns benchmark wall measurement. -- [x] Preserve clear attribution. - ---- - -## 16. Documentation and Release Metadata - -- [x] Update architecture docs with ownership/lifetime/cancellation boundaries. -- [x] Update events docs: injected dispatcher primary; static bus compatibility-only. -- [x] Update HTTP concurrency docs for rolling scheduling and cancellation semantics. -- [x] Update webhook replay docs with atomic/fail-closed requirements. -- [x] Update gRPC inbound docs for the host-runtime bridge and wire-error data minimization. -- [x] Update gRPC generated/native docs for deterministic adapter behavior. -- [x] Update email docs for persistent-worker connection ownership. -- [x] Update sendmail docs for timeout/cancellation/POSIX optional behavior. -- [x] Update security docs with secret/PII redaction guarantees. -- [x] Update performance docs with persistent-runtime guidance. -- [x] Update testing docs with isolation, fake cancellation and fake inbound-runtime examples. -- [x] Update release checklist with static-state, monotonic-time, cancellation, optional-cold and secret-sentinel gates. -- [x] Keep README examples aligned with released APIs; no breaking public API rewrite was introduced by the hardening batches. -- [x] Keep Composer requirements/suggestions synchronized with real runtime behavior. - ---- - -## 17. Likely File Touch Map - -This is a planning map, not a requirement to modify every file. - -### Core/runtime support - -- src/Core/Event/* -- src/Core/Support/Clock.php -- src/Core/Support/Sleeper.php -- src/Core/Support/RetryExecutor.php -- new minimal cancellation support if required - -### HTTP - -- src/Http/HttpClient.php -- src/Http/HttpClientConfig.php -- optional new native HttpClientFactory or equivalent -- src/Http/Concurrent/CurlMultiTransport.php -- src/Http/Concurrent/RequestPool.php -- src/Http/Transport/CurlTransport.php -- src/Http/Middleware/* -- src/Http/Cookie/CookieJar.php -- src/Resilience/CircuitBreaker.php -- src/Resilience/RateLimiter.php - -### Email - -- src/Email/Email.php -- src/Email/Emailer.php -- src/Email/EmailSenderFactory.php -- src/Email/EmailReceiverFactory.php -- src/Email/EmailMailboxFactory.php -- src/Email/Config/EmailLimits.php -- src/Email/Config/DkimConfig.php where useful -- src/Email/Transport/SendmailTransport.php -- src/Email/Transport/SmtpTransport.php -- src/Email/Receiver/SpoolEmailReceiver.php -- src/Email/Mailbox/SocketMailboxRuntime.php -- src/Email/Mailbox/ImapSocketTransport.php -- src/Email/Mailbox/Pop3SocketTransport.php -- src/Email/Parser/BounceParser.php -- relevant tests - -### Webhook - -- src/Webhook/Webhook.php -- src/Webhook/WebhookSender.php -- src/Webhook/WebhookReceiver.php -- src/Webhook/WebhookVerifier.php -- src/Webhook/Contracts/WebhookReplayStore.php -- src/Webhook/Replay/InMemoryWebhookReplayStore.php -- relevant tests/docs - -### gRPC - -- src/Grpc/GrpcClient.php -- src/Grpc/GrpcInboundDispatcher.php -- src/Grpc/Middleware/RetryMiddleware.php -- src/Grpc/Native/GeneratedStubGrpcInvoker.php -- src/Grpc/Native/* -- src/Grpc/Receiver/* -- src/Grpc/Testing/* -- optional native GrpcClientFactory or equivalent -- relevant tests/docs - -### Benchmarks/release - -- benchmarks/HttpBench.php -- benchmarks/WebhookBench.php -- benchmarks/GrpcBench.php -- benchmarks/EmailBench.php -- benchmarks/LargeEmailBench.php -- benchmarks/ResilienceBench.php -- docs/performance.rst -- docs/release-checklist.rst -- composer.json - ---- - -## 18. Major/Minor Decision Gate - -### Stay on 2.1 when - -- new cancellation/time/factory APIs are additive; -- CommunicationEventBus can remain as a compatibility facade; -- existing constructor signatures can gain only optional parameters or factory alternatives; -- generated gRPC correction can preserve current public contracts; -- rolling HTTP scheduling changes internal behavior without invalidating documented guarantees; -- SendmailTransport can be hardened internally; -- Foundation can migrate to new native builders without removing old TalkingBytes entry points. - -### Promote to the next major only when implementation proves one of these is required - -- removing CommunicationEventBus rather than merely bypassing it; -- making EventDispatcher mandatory in existing public constructors; -- replacing existing public config keys/semantics incompatibly; -- removing/renaming public methods rather than adding better alternatives; -- changing gRPC streaming contracts incompatibly; -- changing Emailer/transport public ownership semantics incompatibly. - -### Next-major cleanup candidates, not 2.1 release gates - -- remove the static CommunicationEventBus completely; -- collapse superseded compatibility factories/methods after a deprecation period; -- consider a persistent SMTP session/connection-reuse abstraction with strict idle/max-message/reset/fork-safety policy; -- reconsider any public cancellation/config APIs that cannot be made cleanly additive; -- remove legacy configuration aliases if they exist and are no longer valuable. - ---- - -## 19. Execution Order - -### Batch 1 — Runtime-state, clock and cancellation foundation ✅ - -- remove primary static-event dependency; -- add/propagate injected dispatch; -- standardize monotonic timing; -- introduce minimal cooperative cancellation; -- add persistent/Fiber isolation tests. - -### Batch 2 — gRPC security and host boundary ✅ - -- remove exception metadata leakage; -- add accepted-exchange/source boundary; -- add cancellation; -- add fake runtime; -- preserve status/deadline/metadata semantics. - -### Batch 3 — Email runtime and sendmail process hardening ✅ - -- event injection; -- mailbox/session ownership; -- monotonic deadlines; -- cancellation-aware watch behavior; -- sendmail child-process supervision; -- optional POSIX process-group safety. - -### Batch 4 — Native protocol composition builders ✅ - -- EmailLimits::fromArray; -- HTTP resolved-profile builder; -- email transport/decorator builder; -- gRPC native/generated/retry builder; -- webhook resolved-policy composition; -- tests proving Foundation no longer needs to recreate protocol mechanics. - -### Batch 5 — Webhook replay acceptance ✅ - -- atomic/fail-closed contract; -- contention/error tests; -- preserve provider neutrality. - -### Batch 6 — HTTP rolling multi scheduler ✅ - -- rolling window; -- stop-scheduling behavior; -- cancellation/cleanup; -- throughput benchmark. - -### Batch 7 — gRPC generated adapter determinism ✅ - -- remove TypeError execution probing; -- deterministic call-shape resolution; -- streaming cancellation/failure cleanup. - -### Batch 8 — Observability and data minimization ✅ - -- raw failure audit; -- secret/PII sentinels; -- transcript/path/subject policy. - -### Batch 9 — Optional cold graphs, docs and benchmarks ✅ - -- optional-capability coldness CI with gRPC/IMAP/POSIX absent where unloadable; -- source-level PCNTL prohibition and Sodium confinement to Ed25519 DKIM; -- native CPU benchmark expansion plus repeated-run soak/state-retention evidence; -- deterministic local I/O tests for rolling HTTP scheduling and cancellation cleanup; -- architecture/security/testing/performance/release documentation; -- Composer extension metadata synchronized with runtime behavior. - -Non-gating follow-ups are intentionally kept separate from the release gate: historical 2.0-versus-2.1 rolling-window I/O comparison, disk-backed spool microbenchmarks, sendmail child-process microbenchmarks, and streaming cancellation micro-overhead measurements. - -### Batch 10 — Exact-head release gate ✅ - -The pre-final implementation head feb0854a2659042660e295cf8e4ff1f6cfe40cf1 passed the complete release workflow, including PHP 8.4/8.5, prefer-lowest/prefer-stable QA, static analysis, native benchmarks, clean install, Mailpit integration, optional-capability coldness, and warning-free Sphinx documentation. - -The final plan/documentation finalization commit is the release candidate. Batch 10 is complete when the complete workflow is green on that exact head; any subsequent code or documentation change must become a new candidate and repeat the gate. - ---- - -## 20. Foundation 3 Handoff - -After TalkingBytes 2.1 is released, the following are **Foundation 3 point 26.9 handoff instructions**, not unfinished TalkingBytes tasks: - -- raise the Foundation communication floor to ^2.1 only when the released APIs are consumed; -- keep named application profile lookup in Foundation; -- keep path/secret resolution and production policy in Foundation; -- keep DI lifetime selection in Foundation; -- keep CacheLayerWebhookReplayStore in Foundation; -- keep gRPC handler service lookup in Foundation; -- keep ProcessRunner for console/scheduler/application subprocesses in Foundation; -- map Foundation worker heartbeat/stop/release replacement into TalkingBytes cancellation; -- remove duplicated HTTP auth/cookie/retry/rate-limit/circuit/idempotency composition when consuming TalkingBytes native composition; -- remove duplicated gRPC retry/native/generated composition when consuming TalkingBytes ownership; -- remove duplicated webhook retry/signing composition where TalkingBytes consumes resolved values directly; -- remove duplicated email transport/fallback/retry/rate-limit/DKIM composition where TalkingBytes factories consume resolved config; -- replace manual EmailLimits construction with TalkingBytes parsing; -- keep default From and notification/template routing as Foundation application policy; -- keep HTTP clients scoped when mutable state is attached; -- route inbound gRPC through the new host-controlled request/response boundary; -- prove communication secrets are absent from generated metadata, cache keys and logs; -- add direct-TalkingBytes-versus-Foundation bridge benchmark attribution in Foundation; -- close Foundation runtime plan point 26.9 only on Foundation's own exact-head green CI. - -### Expected Foundation simplification targets - -The follow-up should materially reduce logic in: - -- src/Communication/CommunicationProfiles.php; -- src/Communication/CommunicationGraphFactory.php; -- src/Notifications/EmailProfiles.php; -- src/Notifications/NotificationGraphFactory.php. - -Do not delete the host-policy parts of those classes merely to reduce line count. - ---- - -## 21. Completion Gate - -TalkingBytes 2.1 is complete only when: - -- [x] no primary runtime path depends on process-global CommunicationEventBus state; -- [x] temporary global runtime state is scoped/restored and cannot span user/Fiber suspension paths; -- [x] elapsed-time/deadline logic uses monotonic time where appropriate; -- [x] retry/watch/stream/process waits support cooperative cancellation where materially useful; -- [x] mutable protocol/session/resilience state has explicit lifetime semantics; -- [x] sequential and Fiber-interleaved isolation tests pass; -- [x] webhook replay is atomic/fail-closed by contract and tests; -- [x] no CacheLayer/Foundation/Omnibus runtime dependency was introduced; -- [x] inbound gRPC has a host-controllable accepted-exchange boundary; -- [x] inbound gRPC wire errors do not reveal internal exception classes/messages/traces; -- [x] generated gRPC adapter no longer relies on broad TypeError execution probing; -- [x] native inbound/outbound email APIs remain authoritative; -- [x] sendmail timeout/cancellation/process-tree cleanup is deterministic; -- [x] posix use is optional and pcntl is not required/default; -- [x] HTTP multi scheduling uses a rolling concurrency window with deterministic mixed fast/slow evidence; -- [x] Foundation protocol-composition duplication has corresponding native TalkingBytes APIs ready for consumption; -- [x] secret/PII sentinel tests pass across protocol observability; -- [x] unrelated optional capabilities remain cold until selected; -- [x] native protocol benchmark and soak evidence is recorded; -- [x] PHPForge QA/static/security gates pass on supported PHP/dependency matrices; -- [x] documentation builds warning-free; -- [x] release metadata/examples match final APIs; -- Release operation (outside implementation completion): tag/release only the final verified exact head; tagging itself is not an open TalkingBytes implementation task. - ---- - -## 22. Explicitly Out of Scope - -Do not use 2.1 to add: - -- another universal communication envelope; -- Foundation-specific service providers/configuration; -- CacheLayer, DBLayer or Omnibus runtime dependencies; -- an HTTP application server/framework; -- a general worker supervisor; -- a generic process-management package; -- fork-based HTTP/email concurrency; -- a custom gRPC wire implementation; -- application-specific named profile ownership; -- unrelated queue/messaging functionality; -- a major architectural rewrite that does not directly improve protocol correctness, runtime ownership, performance or Foundation integration. - ---- - -## 23. Immediate Next Step - -TalkingBytes 2.1 implementation work is complete. - -1. Keep the final verified release-candidate head frozen. -2. Tag/release only that verified head. -3. Return to Foundation 3 runtime point 26.9 and consume the released TalkingBytes 2.1 APIs. -4. Remove the duplicated Foundation protocol-composition logic listed in Section 20 while preserving Foundation-owned profile lookup, DI lifetime, path/secret policy, replay storage and worker supervision. - -The optional benchmark follow-ups listed in Sections 11 and 15 are performance-research items, not 2.1 release blockers. 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 332738f..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 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/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/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/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/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 1a45150..b3954b8 100644 --- a/src/Webhook/WebhookSender.php +++ b/src/Webhook/WebhookSender.php @@ -18,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; @@ -127,8 +128,8 @@ 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); 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/CharsetDecoderTest.php b/tests/CharsetDecoderTest.php index c83be72..cf81b09 100644 --- a/tests/CharsetDecoderTest.php +++ b/tests/CharsetDecoderTest.php @@ -89,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/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 3d958cc..6b750ac 100644 --- a/tests/WebhookReceiverTest.php +++ b/tests/WebhookReceiverTest.php @@ -41,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.'); @@ -173,3 +173,51 @@ public function claim(string $namespace, string $deliveryId, int $ttlSeconds): b 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'); +});