diff --git a/.gitignore b/.gitignore index 38ed7e158f9..8cb764e486e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. +# Claude configuration +.claude/ + # User-specific files *.suo *.user diff --git a/Directory.Build.props b/Directory.Build.props index 745654af745..2276d8ed0df 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ Copyright © 2013-$([System.DateTime]::Now.Year) Akka.NET Team Akka.NET Team - 1.5.34 + 1.5.56 akkalogo.png https://getakka.net/ Apache-2.0 @@ -50,7 +50,15 @@ true - Placeholder for nightlies* + Akka.NET v1.5.56 is a patch release containing important bug fixes for Akka.Remote and Akka.Streams. + +**Bug Fixes:** + +* [Fix: Akka.Remote should not shutdown on invalid TLS traffic](https://github.com/akkadotnet/akka.net/pull/7952) - Fixes [issue #7938](https://github.com/akkadotnet/akka.net/issues/7938) where invalid traffic (like HTTP requests) hitting a TLS-enabled Akka.Remote port would cause the entire ActorSystem to shut down. Server now rejects invalid connections gracefully without terminating. + +* [fix(streams): prevent race condition in ChannelSource on channel completion](https://github.com/akkadotnet/akka.net/pull/7951) - Fixes [issue #7940](https://github.com/akkadotnet/akka.net/issues/7940) where a `NullReferenceException` could occur when completing a `ChannelWriter` while the stream is waiting for data. Added atomic flag to prevent race condition between `OnReaderComplete` and `OnValueRead` callbacks. + +To see the full set of changes in Akka.NET v1.5.56, click here: https://github.com/akkadotnet/akka.net/milestone/139?closed=1 @@ -72,4 +80,4 @@ true snupkg - + \ No newline at end of file diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0012691cf0d..4d0dfc6253f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,189 @@ -#### 1.5.48 August 14th, 2025 #### +#### 1.5.57-beta1 December 2nd, 2025 #### -**Placeholder for nightly build** +Akka.NET v1.5.57-beta1 is a beta release containing a significant new feature for structured/semantic logging. + +**New Features:** + +* [Add native semantic logging support with property extraction](https://github.com/akkadotnet/akka.net/pull/7955) - Fixes [issue #7932](https://github.com/akkadotnet/akka.net/issues/7932). This release adds comprehensive structured logging support to Akka.NET with both positional (`{0}`) and named (`{PropertyName}`) message template parsing, enabling seamless integration with modern logging frameworks like Serilog, NLog, and Microsoft.Extensions.Logging. Key capabilities include: + - New `LogMessage.PropertyNames` and `GetProperties()` APIs for property extraction + - `SemanticLogMessageFormatter` as the new default formatter + - Performance optimized with 75% allocation reduction compared to the previous implementation + - Zero new dependencies and fully backward compatible + - EventFilter support for semantic templates in unit tests + +1 contributor since release 1.5.56 + +| COMMITS | LOC+ | LOC- | AUTHOR | +| --- | --- | --- | --- | +| 1 | 2317 | 14 | Aaron Stannard | + +To [see the full set of changes in Akka.NET v1.5.57-beta1, click here](https://github.com/akkadotnet/akka.net/milestone/140?closed=1) + +#### 1.5.56 November 25th, 2025 #### + +Akka.NET v1.5.56 is a patch release containing important bug fixes for Akka.Remote and Akka.Streams. + +**Bug Fixes:** + +* [Fix: Akka.Remote should not shutdown on invalid TLS traffic](https://github.com/akkadotnet/akka.net/pull/7952) - Fixes [issue #7938](https://github.com/akkadotnet/akka.net/issues/7938) where invalid traffic (like HTTP requests) hitting a TLS-enabled Akka.Remote port would cause the entire ActorSystem to shut down. Server now rejects invalid connections gracefully without terminating. + +* [fix(streams): prevent race condition in ChannelSource on channel completion](https://github.com/akkadotnet/akka.net/pull/7951) - Fixes [issue #7940](https://github.com/akkadotnet/akka.net/issues/7940) where a `NullReferenceException` could occur when completing a `ChannelWriter` while the stream is waiting for data. Added atomic flag to prevent race condition between `OnReaderComplete` and `OnValueRead` callbacks. + +1 contributor since release 1.5.55 + +| COMMITS | LOC+ | LOC- | AUTHOR | +| --- | --- | --- | --- | +| 2 | 162 | 6 | Aaron Stannard | + +To [see the full set of changes in Akka.NET v1.5.56, click here](https://github.com/akkadotnet/akka.net/milestone/139?closed=1) + +#### 1.5.55 October 26th, 2025 #### + +Akka.NET v1.5.55 is a patch release containing important stability and security improvements for Akka.Remote. + +**Akka.Remote Stability Improvements:** + +* [Akka.Remote: harden EndpointWriter against serialization failures](https://github.com/akkadotnet/akka.net/pull/7925) - Fixes [issue #7922](https://github.com/akkadotnet/akka.net/issues/7922) by hardening the `EndpointWriter` against a broader range of potential serialization failures, improving overall remoting stability. + +**Akka.Remote Security Improvements:** + +* [Custom certificate validation with single execution path - fixes mTLS asymmetry bug](https://github.com/akkadotnet/akka.net/pull/7921) - Fixes [issue #7914](https://github.com/akkadotnet/akka.net/issues/7914) by introducing programmatic certificate validation helpers through the new `CertificateValidation` factory class. This release adds 7 new validation helper methods including `ValidateChain()`, `ValidateHostname()`, `PinnedCertificate()`, `ValidateSubject()`, `ValidateIssuer()`, `Combine()`, and `ChainPlusThen()`. The update also fixes an mTLS asymmetry bug where server-side hostname validation was not being applied consistently with client-side validation, all while maintaining full backward compatibility with existing HOCON-based validation. + +* [Fix DotNettySslSetup being ignored when HOCON has valid SSL config](https://github.com/akkadotnet/akka.net/pull/7919) - Fixes [issue #7917](https://github.com/akkadotnet/akka.net/issues/7917) where programmatic `DotNettySslSetup` settings were incorrectly being overridden by HOCON configuration. Programmatic configuration now correctly takes precedence over HOCON defaults as intended. + +1 contributor since release 1.5.54 + +| COMMITS | LOC+ | LOC- | AUTHOR | +| --- | --- | --- | --- | +| 3 | 1605 | 289 | Aaron Stannard | + + +To [see the full set of changes in Akka.NET v1.5.55, click here](https://github.com/akkadotnet/akka.net/milestone/138?closed=1) + +#### 1.5.54 October 17th, 2025 #### + +Akka.NET v1.5.54 is a patch release containing important bug fixes for Akka.Streams and Akka.DistributedData. + +**Bug Fixes:** + +* [Fix SourceRef.Source and SinkRef.Sink non-idempotent property bug](https://github.com/akkadotnet/akka.net/pull/7907) - Fixes [issue #7895](https://github.com/akkadotnet/akka.net/issues/7895) where `ISourceRef.Source` and `ISinkRef.Sink` properties created new stage instances on every access, causing race conditions and intermittent subscription timeouts. These properties are now idempotent using `Lazy`, preventing failures from accidental property access (debugger inspection, logging, serialization frameworks). + +* [Fix LWWDictionary.Delta ArgumentNullException when underlying delta is null](https://github.com/akkadotnet/akka.net/pull/7912) - Fixes [issue #7910](https://github.com/akkadotnet/akka.net/issues/7910) where `LWWDictionary.Delta` would throw `ArgumentNullException` when the underlying `ORDictionary.Delta` was `null`, which is a legitimate state after initialization or calling `ResetDelta()`. + +1 contributor since release 1.5.53 + +| COMMITS | LOC+ | LOC- | AUTHOR | +| --- | --- | --- | --- | +| 2 | 159 | 20 | Aaron Stannard | + + +To [see the full set of changes in Akka.NET v1.5.54, click here](https://github.com/akkadotnet/akka.net/milestone/137?closed=1) + +#### 1.5.53 October 9th, 2025 #### + +Akka.NET v1.5.53 is a security patch containing important fixes for TLS/SSL hostname validation and improved error diagnostics for certificate authentication issues. + +**Security Fixes:** + +* [Fix TLS hostname validation bug and add configurable validation](https://github.com/akkadotnet/akka.net/pull/7897) - Fixes a critical bug where TLS clients validated against their own certificate DNS name instead of the remote server address, particularly affecting mutual TLS scenarios. This release also adds a new `validate-certificate-hostname` configuration option to `akka.remote.dot-netty.tcp` (defaults to `false` for backward compatibility) and introduces type-safe validation APIs through the new `TlsValidationCallbacks` factory class. + +**Improvements:** + +* [Improve TLS/SSL certificate error messages during handshake failures](https://github.com/akkadotnet/akka.net/pull/7891) - Provides human-readable, actionable error messages for TLS/SSL certificate validation failures with detailed troubleshooting guidance, significantly improving the developer experience when configuring certificate-based authentication. + +1 contributor since release 1.5.52 + +| COMMITS | LOC+ | LOC- | AUTHOR | +| --- | --- | --- | --- | +| 2 | 1060 | 77 | Aaron Stannard | + + +To [see the full set of changes in Akka.NET v1.5.53, click here](https://github.com/akkadotnet/akka.net/milestone/136?closed=1) + +#### 1.5.52 October 6th, 2025 #### + +**SECURITY PATCH** + +Akka.NET v1.5.52 is a security patch containing crucial fixes for enforcing certificate-based authentication using mTLS enforcement. Please see https://getakka.net/articles/remoting/security.html for details on how this works. + +* [Akka.Remote: implement mutual TLS authentication support](https://github.com/akkadotnet/akka.net/pull/7851) +* [Akka.Remote: validate SSL certificate private key access at server startup](https://github.com/akkadotnet/akka.net/pull/7847) + +Other fixes: + +* [Akka.Cluster.Sharding: ShardedDaemonSets: randomize starting worker index](https://github.com/akkadotnet/akka.net/pull/7857) + +1 contributors since release 1.5.51 + +| COMMITS | LOC+ | LOC- | AUTHOR | +| --- | --- | --- | --- | +| 3 | 1193 | 149 | Aaron Stannard | + + +To [see the full set of changes in Akka.NET v1.5.52, click here](https://github.com/akkadotnet/akka.net/milestone/135?closed=1) + +#### 1.5.51 October 1st, 2025 #### + +Akka.NET v1.5.51 is a minor patch containing a remoting bug fix and add required codes to support persistence health check. + +* [Remote: Fix DotNetty TLS handshake error handling](https://github.com/akkadotnet/akka.net/pull/7839) +* [Persistence: Add health check handling code](https://github.com/akkadotnet/akka.net/pull/7842) + +2 contributors since release 1.5.50 + +| COMMITS | LOC+ | LOC- | AUTHOR | +|---------|------|------|---------------------| +| 1 | 609 | 31 | Aaron Stannard | +| 1 | 139 | 5 | Gregorius Soedharmo | + +To [see the full set of changes in Akka.NET v1.5.51, click here](https://github.com/akkadotnet/akka.net/milestone/134?closed=1) + +#### 1.5.50 September 22nd, 2025 #### + +Akka.NET v1.5.50 is a minor patch containing a bug fix. + +* [Remote: Propagate error from DotNetty TLS Handshake failure to Akka.Remote](https://github.com/akkadotnet/akka.net/pull/7824) + +1 contributor since release 1.5.49 + +| COMMITS | LOC+ | LOC- | AUTHOR | +|---------|------|------|---------------------| +| 1 | 187 | 1 | Gregorius Soedharmo | + +To [see the full set of changes in Akka.NET v1.5.50, click here](https://github.com/akkadotnet/akka.net/milestone/133?closed=1) + +#### 1.5.49 September 10th, 2025 #### + +Akka.NET v1.5.49 is a minor patch containing several bug fixes. + +* [Core: Fix IIS/Windows Service console race condition](https://github.com/akkadotnet/akka.net/pull/7793) +* [DData: Fix Replicator.ReceiveUnsubscribe boolean logic](https://github.com/akkadotnet/akka.net/pull/7809) +* [Streams: Fix ConcurrentAsyncCallback with ChannelSource throws NRE](https://github.com/akkadotnet/akka.net/pull/7808) + +3 contributors since release 1.5.48 + +| COMMITS | LOC+ | LOC- | AUTHOR | +|---------|------|------|---------------------| +| 18 | 6011 | 9343 | Aaron Stannard | +| 18 | 3760 | 3880 | Gregorius Soedharmo | +| 1 | 1 | 1 | dependabot[bot] | + +To [see the full set of changes in Akka.NET v1.5.49, click here](https://github.com/akkadotnet/akka.net/milestone/132?closed=1) + +#### 1.5.48 August 21st, 2025 #### + +Akka.NET v1.5.48 is a minor patch containing stability improvement to Akka.TestKit. + +* [TestKit: Fix deadlock during parallel test execution](https://github.com/akkadotnet/akka.net/pull/7787) + +2 contributors since release 1.5.47 + +| COMMITS | LOC+ | LOC- | AUTHOR | +|---------|------|------|---------------------| +| 4 | 5494 | 5561 | Aaron Stannard | +| 2 | 204 | 66 | Gregorius Soedharmo | + +To [see the full set of changes in Akka.NET v1.5.48, click here](https://github.com/akkadotnet/akka.net/milestone/131?closed=1) #### 1.5.47 August 12th, 2025 #### diff --git a/build-system/azure-pipeline.mntr-template.yaml b/build-system/azure-pipeline.mntr-template.yaml index ec0d732cd13..f1c237867bc 100644 --- a/build-system/azure-pipeline.mntr-template.yaml +++ b/build-system/azure-pipeline.mntr-template.yaml @@ -20,6 +20,19 @@ jobs: inputs: packageType: 'sdk' useGlobalJson: true + + # Set the Incrementalist base branch based on PR target branch + - pwsh: | + if ('$(Build.Reason)' -eq 'PullRequest') { + # Extract branch name from refs/heads/branch format + $targetBranch = '$(System.PullRequest.TargetBranch)'.Replace('refs/heads/', '') + Write-Host "PR detected - using base branch: $targetBranch" + Write-Host "##vso[task.setvariable variable=IncrementalistBaseBranch]$targetBranch" + } else { + Write-Host "Not a PR - using default base branch: dev" + Write-Host "##vso[task.setvariable variable=IncrementalistBaseBranch]dev" + } + displayName: 'Set Incrementalist base branch' - script: dotnet tool restore displayName: 'Restore dotnet tools' diff --git a/build-system/azure-pipeline.template.yaml b/build-system/azure-pipeline.template.yaml index 7cde35b6587..09d1ee5d667 100644 --- a/build-system/azure-pipeline.template.yaml +++ b/build-system/azure-pipeline.template.yaml @@ -27,6 +27,19 @@ jobs: inputs: packageType: 'sdk' useGlobalJson: true + + # Set the Incrementalist base branch based on PR target branch + - pwsh: | + if ('$(Build.Reason)' -eq 'PullRequest') { + # Extract branch name from refs/heads/branch format + $targetBranch = '$(System.PullRequest.TargetBranch)'.Replace('refs/heads/', '') + Write-Host "PR detected - using base branch: $targetBranch" + Write-Host "##vso[task.setvariable variable=IncrementalistBaseBranch]$targetBranch" + } else { + Write-Host "Not a PR - using default base branch: dev" + Write-Host "##vso[task.setvariable variable=IncrementalistBaseBranch]dev" + } + displayName: 'Set Incrementalist base branch' - script: dotnet tool restore displayName: 'Restore dotnet tools' diff --git a/build-system/pr-validation.yaml b/build-system/pr-validation.yaml index 3e52eafc15f..1a49c58fd0c 100644 --- a/build-system/pr-validation.yaml +++ b/build-system/pr-validation.yaml @@ -59,7 +59,7 @@ jobs: name: "netfx_tests_windows" displayName: ".NET Framework Unit Tests (Windows)" vmImage: "windows-latest" - command: "dotnet incrementalist run --config .incrementalist/testsOnly.json -- test -c Release --no-build --framework net48 --logger:trx --results-directory TestResults" + command: "dotnet incrementalist run --config .incrementalist/testsOnly.json --branch $(IncrementalistBaseBranch) -- test -c Release --no-build --framework net48 --logger:trx --results-directory TestResults" outputDirectory: "TestResults" artifactName: "netfx_tests_windows-$(Build.BuildId)" @@ -80,7 +80,7 @@ jobs: name: "net_tests_windows" displayName: ".NET Unit Tests (Windows)" vmImage: "windows-latest" - command: "dotnet incrementalist run --config .incrementalist/testsOnly.json -- test -c Release --no-build --framework net8.0 --logger:trx --results-directory TestResults" + command: "dotnet incrementalist run --config .incrementalist/testsOnly.json --branch $(IncrementalistBaseBranch) -- test -c Release --no-build --framework net8.0 --logger:trx --results-directory TestResults" outputDirectory: "TestResults" artifactName: "net_tests_windows-$(Build.BuildId)" @@ -89,7 +89,7 @@ jobs: name: "net_tests_linux" displayName: ".NET Unit Tests (Linux)" vmImage: "ubuntu-latest" - command: "dotnet incrementalist run --config .incrementalist/testsOnly.json -- test -c Release --no-build --framework net8.0 --logger:trx --results-directory TestResults" + command: "dotnet incrementalist run --config .incrementalist/testsOnly.json --branch $(IncrementalistBaseBranch) -- test -c Release --no-build --framework net8.0 --logger:trx --results-directory TestResults" outputDirectory: "TestResults" artifactName: "net_tests_linux-$(Build.BuildId)" @@ -98,7 +98,7 @@ jobs: name: "net_mntr_windows" displayName: ".NET Multi-Node Tests (Windows)" vmImage: "windows-latest" - command: "dotnet incrementalist run --config .incrementalist/mutliNodeOnly.json -- test -c Release --no-build --framework net8.0 --logger:trx --results-directory TestResults/multinode" + command: "dotnet incrementalist run --config .incrementalist/mutliNodeOnly.json --branch $(IncrementalistBaseBranch) -- test -c Release --no-build --framework net8.0 --logger:trx --results-directory TestResults/multinode" outputDirectory: "TestResults" artifactName: "net_mntr_windows-$(Build.BuildId)" mntrFailuresDir: 'TestResults\\multinode' diff --git a/build-system/windows-release.yaml b/build-system/windows-release.yaml index de6187147a4..29d11345e8f 100644 --- a/build-system/windows-release.yaml +++ b/build-system/windows-release.yaml @@ -3,7 +3,6 @@ pool: vmImage: windows-latest - demands: Cmd trigger: branches: @@ -45,4 +44,4 @@ steps: title: '$(projectName) v$(Build.SourceBranchName)' releaseNotesFile: 'RELEASE_NOTES.md' assets: | - $(Build.ArtifactStagingDirectory)/nuget/*.nupkg \ No newline at end of file + $(Build.ArtifactStagingDirectory)/nuget/*.nupkg diff --git a/docs/articles/remoting/security.md b/docs/articles/remoting/security.md index 19f7a18d50f..c83663a3e07 100644 --- a/docs/articles/remoting/security.md +++ b/docs/articles/remoting/security.md @@ -5,52 +5,794 @@ title: Network Security # Akka.Remote Security -There are 2 ways you may like to achieve network security when using Akka.Remote: +## Important Context: When You Need TLS -* Transport Layer Security (introduced with Akka.Remote Version 1.2) -* Virtual Private Networks +**Akka.Remote is designed for internal cluster communication and should NOT be exposed to the public internet.** Most Akka.NET deployments run within: -## Akka.Remote with TLS (Transport Layer Security) +* Private networks (VPNs, VPCs) +* Internal data centers +* Kubernetes clusters with network policies +* Behind firewalls with strict ingress rules -The release of Akka.NET version 1.2.0 introduces the default [DotNetty](https://github.com/Azure/DotNetty) transport and the ability to configure [TLS](http://en.wikipedia.org/wiki/Transport_Layer_Security) security across Akka.Remote Actor Systems. In order to use TLS, you must first install a valid SSL certificate on all Akka.Remote hosts that you intend to use TLS. +### When TLS Is Optional -Once you've installed valid SSL certificates, TLS is enabled via your HOCON configuration by setting `enable-ssl = true` and configuring the `ssl` HOCON configuration section like below: +For many deployments, TLS is not strictly necessary: + +* **Internal networks only** - If your cluster runs entirely within a trusted network boundary +* **Development/staging environments** - Where data sensitivity is low +* **Kubernetes with network policies** - Where the container network provides isolation + +### When TLS Is Recommended + +You should enable TLS when: + +* **Crossing network boundaries** - Communication between data centers or cloud regions +* **Public internet transit** - Any traffic over public networks (even with VPN) +* **Compliance requirements** - PCI-DSS, HIPAA, or other regulatory needs +* **Defense-in-depth** - Additional security layer even on private networks +* **Multi-tenant environments** - Shared infrastructure with other applications + +## Security Layers + +Akka.Remote security operates on three complementary layers: + +1. **Network Isolation** - Using VPNs or private networks to restrict which machines can reach your actor systems +2. **Transport Encryption** - Using TLS to encrypt all communication between nodes +3. **Authentication** - Using mutual TLS to verify the identity of all connecting nodes + +You should use **all three layers** in production for defense-in-depth security. + +## TLS (Transport Layer Security) Overview + +TLS encryption was introduced in Akka.NET v1.2 with the DotNetty transport. It provides: + +**What TLS Protects Against:** + +* Eavesdropping (all messages are encrypted) +* Man-in-the-middle attacks (certificates verify server identity) +* Network packet injection (cryptographic integrity checks) + +**What TLS Does NOT Protect Against:** + +* Misconfigured certificates (see startup validation below) +* Compromised private keys (rotate certificates regularly) +* Application-level authorization (implement this separately) + +## Certificate Validation: Independent Control + +**New in Akka.NET v1.5.52+:** Certificate validation is now split into two independent settings for greater flexibility. + +### Two Types of Validation + +1. **Chain Validation** (`suppress-validation`) - Validates certificate against trusted CAs +2. **Hostname Validation** (`validate-certificate-hostname`) - Validates certificate CN/SAN matches target hostname + +These settings are **independent** and can be configured separately based on your deployment scenario. + +### Chain Validation + +The `suppress-validation` setting controls whether the certificate chain is validated against trusted root CAs. + +**Default Certificate Stores Used:** + +When `suppress-validation = false`, .NET's `SslStream` validates certificates against the operating system's trusted root certificate stores: + +* **Windows**: Uses the [Windows Certificate Store](https://learn.microsoft.com/en-us/windows-hardware/drivers/install/local-machine-and-current-user-certificate-stores) - specifically the `Trusted Root Certification Authorities` store +* **Linux**: Uses the system's CA bundle (typically `/etc/ssl/certs/ca-certificates.crt` or `/etc/pki/tls/certs/ca-bundle.crt`) +* **macOS**: Uses the Keychain Access Trusted Certificates + +The validation process follows [RFC 5280 (X.509 PKI Certificate and CRL Profile)](https://datatracker.ietf.org/doc/html/rfc5280) and [RFC 6125 (Service Identity Verification)](https://datatracker.ietf.org/doc/html/rfc6125). + +#### Enabled (Recommended) + +When `suppress-validation = false` (the default when SSL is enabled): + +**What it validates:** + +* Certificate chain against system trusted root CAs +* Certificate expiration dates +* Certificate hasn't been revoked (if CRL/OCSP configured) + +**Does NOT validate:** + +* Hostname matching (see Hostname Validation section below) + +**When to use:** Always in production and any networked environment. + +#### Disabled (Use With Caution) + +When `suppress-validation = true`: + +**What it skips:** + +* Certificate chain validation (accepts self-signed certificates) +* Expiration date checks +* CA trust checks + +**When it's acceptable:** + +* Local development on `localhost` only +* Automated testing with self-signed test certificates +* Initial TLS setup/debugging before obtaining proper certificates + +**When it's NOT acceptable:** + +* Any production environment +* Any network-accessible environment (dev, staging, QA) +* Any environment processing sensitive data +* Any multi-tenant environment + +### Validation Strategies: HOCON vs Programmatic (v1.5.52+) + +Two independent validation decisions determine your TLS security posture: + +1. **Chain Validation** - Verify certificate against trusted CAs (`suppress-validation`) +2. **Hostname Validation** - Verify certificate CN/SAN matches target (`validate-certificate-hostname`) +3. **Mutual Authentication** - Require both sides authenticate (`require-mutual-authentication`) + +#### Decision Matrix: Which Combination to Use + +| Use Case | suppress-validation | validate-hostname | mutual-auth | Config Approach | +|----------|---------------------|-------------------|-------------|-----------------| +| **P2P Cluster (Default)** | `false` | `false` | `true` | HOCON ✓ or Programmatic | +| **Client-Server with Shared Cert** | `false` | `true` | `true` | HOCON ✓ or Programmatic | +| **Development/Testing** | `true` | `false` | `false` | HOCON only | +| **Certificate Pinning** | `false` | `false` | `true` | **Programmatic required** | +| **Custom Subject/Issuer Validation** | `false` | `false` | `true` | **Programmatic required** | + +#### HOCON Configuration Approach + +When `validate-certificate-hostname = false` (the default): + +* Skips hostname validation +* Only validates certificate chain (if `suppress-validation = false`) +* **Best for:** Mutual TLS with per-node certificates, IP-based connections, Kubernetes dynamic discovery + +When `validate-certificate-hostname = true`: + +* Certificate CN (Common Name) or SAN (Subject Alternative Name) must match the target hostname +* Traditional TLS hostname validation as used in HTTPS +* **Best for:** Client-server architectures with shared certificates and stable DNS names + +**HOCON Example - P2P Cluster (Common Default):** + +```hocon +akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false # Validate CA chain + require-mutual-authentication = true # Both sides authenticate + validate-certificate-hostname = false # Default: Allow per-node certs + certificate { + use-thumbprint-over-file = true + thumbprint = "2531c78c51e5041d02564697a88af8bc7a7ce3e3" + } + } +} +``` + +**HOCON Example - Client-Server with Hostname Validation:** + +```hocon +akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false # Validate CA chain + require-mutual-authentication = true # Both sides authenticate + validate-certificate-hostname = true # Hostname must match + certificate { + use-thumbprint-over-file = true + thumbprint = "2531c78c51e5041d02564697a88af8bc7a7ce3e3" + } + } +} +``` + +#### Programmatic Configuration Approach + +Use `DotNettySslSetup` with `CertificateValidation` helpers when you need: + +* **Certificate pinning** - Accept only specific certificates +* **Subject/Issuer validation** - Custom certificate attribute checks +* **Custom business logic** - Domain-specific validation rules +* **Dynamic validation** - Load rules from runtime sources + +See [Programmatic Certificate Validation](#programmatic-certificate-validation-v1555) below for detailed examples. + +### Self-Signed Certificates: The Right Way + +If you must use self-signed certificates (development/testing): + +#### Option 1: Trust the Self-Signed CA (Better) + +```powershell +# Generate self-signed CA +$ca = New-SelfSignedCertificate -Subject "CN=Dev-CA" -CertStoreLocation Cert:\CurrentUser\My -KeyUsage CertSign + +# Export and import to Trusted Root +Export-Certificate -Cert $ca -FilePath dev-ca.cer +Import-Certificate -FilePath dev-ca.cer -CertStoreLocation Cert:\LocalMachine\Root + +# Generate server cert signed by CA +New-SelfSignedCertificate -Subject "CN=localhost" -Signer $ca -CertStoreLocation Cert:\LocalMachine\My +``` + +**Configuration:** + +```hocon +akka.remote.dot-netty.tcp.ssl { + suppress-validation = false # ✓ Still validates, but trusts your CA + certificate { + use-thumbprint-over-file = true + thumbprint = "server-cert-thumbprint" + } +} +``` + +**Pros:** + +* Maintains validation checks +* Catches expiration/configuration errors +* More realistic test environment + +#### Option 2: Suppress Validation (Quick but Dangerous) + +```hocon +akka.remote.dot-netty.tcp.ssl { + suppress-validation = true # ⚠️ Development ONLY + certificate { + path = "self-signed.pfx" + password = "password" + } +} +``` + +**Pros:** + +* Quick setup +* No certificate installation needed + +**Cons:** + +* Doesn't catch real configuration errors +* False sense of security +* Easy to accidentally deploy to production + +**WARNING:** Never commit `suppress-validation = true` to version control for production configs. Use environment-specific configuration files. + +## Certificate Configuration + +### Option 1: Certificate File (Recommended for Development) ```hocon -akka { - loglevel = DEBUG - actor { - provider = remote +akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false # IMPORTANT: Never use true in production! + certificate { + path = "path/to/certificate.pfx" + password = "certificate-password" + # Optional: Specify key storage flags + flags = [ "exportable" ] + } } - remote { - dot-netty.tcp { - port = 0 - hostname = 127.0.0.1 - enable-ssl = true - log-transport = true - ssl { - suppress-validation = true - certificate { - # valid ssl certificate must be installed on both hosts - path = "" - password = "" - # flags is optional: defaults to "default-flag-set" key storage flag - # other available storage flags: - # exportable | machine-key-set | persist-key-set | user-key-set | user-protected - flags = [ "default-flag-set" ] - } - } +} +``` + +**When to use:** Development, testing, containerized environments where you can mount certificate files. + +**Pros:** + +* Easy to deploy with containers +* Simple to version control (store path, not certificate) +* Works well with configuration management tools + +**Cons:** + +* Certificate files can be copied if filesystem is compromised +* Requires file system access for certificate deployment + +### Option 2: Windows Certificate Store (Recommended for Production) + +```hocon +akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false + certificate { + use-thumbprint-over-file = true + thumbprint = "2531c78c51e5041d02564697a88af8bc7a7ce3e3" + store-name = "My" + store-location = "local-machine" # or "current-user" } } } ``` -## Akka.Remote with Virtual Private Networks +**When to use:** Windows production environments, enterprise deployments with centralized certificate management. + +**Pros:** + +* Leverages Windows ACL for private key protection +* Integrates with enterprise PKI infrastructure +* Supports hardware security modules (HSM) +* Private keys can be marked as non-exportable + +**Cons:** + +* Windows-specific (not portable to Linux) +* Requires administrative access for certificate installation +* More complex initial setup + +**Finding Your Thumbprint:** + +1. Open `certlm.msc` (Local Machine) or `certmgr.msc` (Current User) +2. Navigate to Personal > Certificates +3. Double-click your certificate +4. Go to Details tab +5. Scroll to Thumbprint field +6. Copy the value (remove spaces) + +## Programmatic Certificate Validation (v1.5.55+) + +**New in Akka.NET v1.5.55:** Certificate validation can now be configured programmatically using `DotNettySslSetup` with custom validators. This provides fine-grained control over validation logic while maintaining full backward compatibility with HOCON configuration. + +### When to Use Programmatic Configuration + +Use programmatic setup when you need: + +* **Custom validation logic** - Implement domain-specific validation rules +* **Certificate pinning** - Accept only specific certificates by thumbprint +* **Subject/Issuer validation** - Verify certificate attributes +* **Dynamic configuration** - Load validation rules from runtime sources +* **Composable validators** - Combine multiple validation strategies + +### CertificateValidation Helper Factory + +The `CertificateValidation` static class provides 7 helper methods for common validation patterns: + +#### Basic Chain Validation + +[!code-csharp[ProgrammaticMutualTlsSetup](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=ProgrammaticMutualTlsSetup)] + +#### Certificate Pinning by Thumbprint + +Accept only certificates with specific thumbprints. Prevents man-in-the-middle attacks if CA is compromised: + +[!code-csharp[CertificatePinningExample](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=CertificatePinningExample)] + +#### Custom Validation Logic with ChainPlusThen + +Perform standard chain validation, then apply custom business logic: + +[!code-csharp[CustomValidationLogicExample](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=CustomValidationLogicExample)] + +#### Hostname Validation + +Enable traditional TLS hostname validation (certificate CN/SAN must match target hostname). Use for client-server architectures with shared certificates: + +[!code-csharp[HostnameValidationExample](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=HostnameValidationExample)] + +#### Subject DN Validation + +Accept only certificates with specific subject names: + +[!code-csharp[SubjectValidationExample](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=SubjectValidationExample)] + +### CertificateValidation Helper Methods + +| Method | Purpose | +|--------|---------| +| `ValidateChain()` | CA chain validation with full error details | +| `ValidateHostname()` | Traditional TLS hostname validation (CN/SAN matching) | +| `PinnedCertificate()` | Certificate pinning by thumbprint whitelist | +| `ValidateSubject()` | Subject DN pattern matching (e.g., CN, O, OU) | +| `ValidateIssuer()` | Issuer DN pattern matching | +| `Combine()` | Compose multiple validators (AND logic) | +| `ChainPlusThen()` | Chain validation + custom business logic | + +### Custom Validator Precedence + +When both custom validators and HOCON config are present, custom validators take precedence: + +```csharp +// This validator will be used regardless of HOCON suppress-validation setting +var customValidator = CertificateValidation.ValidateChain(log); +var sslSetup = new DotNettySslSetup( + certificate: cert, + suppressValidation: false, // Ignored when customValidator provided + customValidator: customValidator +); +``` + +This ensures programmatic validation logic always takes priority for explicit security requirements. + +## Startup Certificate Validation (v1.5.52+) -The absolute best practice for securing remote Akka.NET applications today is to make the network around the applications secure - don't use public, open networks! Instead, use a private network to restrict machines that can contact Akka.Remote processes to ones who have your VPN credentials. +**New in Akka.NET v1.5.52:** The transport now validates certificate configuration at startup, preventing runtime failures. + +### What It Validates + +The startup validation verifies: + +* Certificate exists in the specified location +* Certificate has a private key associated +* Application has permissions to access the private key +* Private key is accessible for both RSA and ECDSA algorithms + +This fail-fast validation prevents runtime TLS handshake failures by detecting certificate configuration problems during system initialization. + +### Common Private Key Permission Issues + +**Symptom:** "SSL certificate private key exists but cannot be accessed" + +**Cause:** Application user lacks permissions to the private key file in Windows certificate store. + +**Solution:** Grant private key access to your application user: + +```powershell +# Find the certificate +$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object {$_.Thumbprint -eq "YOUR_THUMBPRINT"} + +# Get private key file location +$keyPath = $cert.PrivateKey.CspKeyContainerInfo.UniqueKeyContainerName +$keyFullPath = "C:\ProgramData\Microsoft\Crypto\RSA\MachineKeys\$keyPath" + +# Grant read permissions +$acl = Get-Acl $keyFullPath +$permission = "DOMAIN\AppUser","Read","Allow" +$accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule $permission +$acl.AddAccessRule($accessRule) +Set-Acl $keyFullPath $acl +``` + +## Understanding Mutual TLS (mTLS) vs Standard TLS (v1.5.52+) + +Akka.NET supports both standard TLS and mutual TLS (mTLS), configured via the `require-mutual-authentication` setting in the [Validation Strategies](#validation-strategies-hocon-vs-programmatic-v1552) section above. + +### Visual Comparison + +**Standard TLS (Server Authentication Only):** + +```mermaid +sequenceDiagram + participant Client + participant Server + + Client->>Server: Connect (no certificate) + Server->>Client: Send server certificate + Client->>Client: Validate server certificate + Client->>Server: Accept connection + Note over Client,Server: Encrypted communication established +``` + +**Mutual TLS (Client + Server Authentication):** + +```mermaid +sequenceDiagram + participant Client + participant Server + + Client->>Server: Connect with client certificate + Server->>Client: Send server certificate + Client->>Client: Validate server certificate + Server->>Server: Validate client certificate + Client->>Server: Accept connection + Server->>Client: Accept connection + Note over Client,Server: Mutually authenticated encryption established +``` + +### When to Enable Mutual TLS + +**Enable mutual TLS (`require-mutual-authentication = true`) when:** + +* All nodes are under your control (typical Akka.NET cluster) ✓ **Recommended** +* You need defense-in-depth security +* Compliance requires bidirectional authentication (PCI-DSS, HIPAA, etc.) +* You want to prevent misconfigured nodes from joining + +**Disable mutual TLS (`require-mutual-authentication = false`) when:** + +* Clients cannot provide certificates (rare in Akka.NET) +* You're using client-server architecture where clients are untrusted +* Backward compatibility with older clients required + +**Default is TRUE for security-by-default posture** (since v1.5.52). + +### Security Benefits of Mutual TLS + +1. **Prevents Asymmetric Connectivity Issues** + * Without mTLS: A node with broken certificate can connect OUT to cluster (client TLS succeeds) + * With mTLS: Node cannot connect without working certificate (enforced both ways) + +2. **Defense-in-Depth** + * Startup validation prevents broken servers + * Mutual TLS prevents broken clients + * Both together provide complete protection + +3. **Identity Verification** + * Every node must prove it owns the certificate + * Prevents certificate theft attacks (attacker needs private key) + +For configuration examples in both HOCON and programmatic styles, see [Validation Strategies](#validation-strategies-hocon-vs-programmatic-v1552) and [Programmatic Certificate Validation](#programmatic-certificate-validation-v1555) sections above. + +## Configuration Examples and Security Analysis + +This section provides concrete examples of different security configurations and their tradeoffs. + +### HOCON Configuration Security Levels + +**Development/Testing Only (INSECURE):** + +[!code-csharp[DevTlsConfig](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=DevTlsConfig)] + +* ⚠️ `suppress-validation = true` accepts ANY certificate (self-signed, expired, invalid chains) +* Vulnerable to man-in-the-middle attacks +* No client authentication +* **Use only:** Local development, never in networked environments + +**Standard TLS (Medium-High Security):** + +[!code-csharp[StandardTlsConfig](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=StandardTlsConfig)] + +* Server proves identity to clients +* All traffic encrypted +* Startup validation prevents misconfigurations +* **Use when:** Mutual TLS is not feasible + +**Mutual TLS with Windows Certificate Store (Maximum Security - RECOMMENDED):** + +[!code-csharp[WindowsCertStoreConfig](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=WindowsCertStoreConfig)] + +* ✓ Both client and server prove identity +* ✓ All traffic encrypted +* ✓ Prevents misconfigured nodes from connecting +* ✓ Private keys protected by Windows ACL +* **Use when:** Production Akka.NET clusters (default recommended configuration) + +**Mutual TLS for P2P Clusters with Per-Node Certificates:** + +Refer to the [Validation Strategies](#validation-strategies-hocon-vs-programmatic-v1552) section for HOCON example showing P2P cluster setup. + +**Client-Server with Hostname Validation:** + +Refer to the [Validation Strategies](#validation-strategies-hocon-vs-programmatic-v1552) section for HOCON example with hostname validation enabled. + +### Programmatic Configuration Security Levels + +For certificate pinning, subject/issuer validation, or custom logic, use programmatic setup: + +[!code-csharp[ProgrammaticMutualTlsSetup](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=ProgrammaticMutualTlsSetup)] + +[!code-csharp[CertificatePinningExample](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=CertificatePinningExample)] + +See [Programmatic Certificate Validation](#programmatic-certificate-validation-v1555) section for more examples. + +## Untrusted Mode + +In addition to TLS, Akka.Remote supports "untrusted mode" which prevents clients from sending system-level messages: + +```hocon +akka.remote { + untrusted-mode = true + + # Whitelist specific actors that can receive remote messages + trusted-selection-paths = [ + "/user/api-handler", + "/user/public-endpoint" + ] +} +``` + +**When to enable:** + +* You're exposing Akka.Remote to untrusted clients +* You want to prevent remote actor creation/supervision +* Defense against malicious remote commands + +**Note:** This does NOT replace TLS encryption. Use both together. + +## Virtual Private Networks (VPNs) + +The best practice for network security is to make the network itself secure. Run Akka.Remote on private networks that require VPN access. + +**Why VPNs matter:** + +* Restricts who can even attempt to connect +* Provides network-level access control +* Adds authentication layer before TLS +* Protects against network scanning/discovery + +### VPN Options + +**Self-Hosted:** + +* [WireGuard](https://www.wireguard.com/) - Modern, fast, simple to configure +* [OpenVPN](https://openvpn.net/) - Mature, widely supported + +**Cloud Provider VPNs:** + +* [AWS Virtual Private Cloud (VPC)](https://aws.amazon.com/vpc/) +* [Azure Virtual Networks (VNet)](https://azure.microsoft.com/en-us/services/virtual-network/) +* [Google Cloud VPC](https://cloud.google.com/vpc) + +**Managed Solutions:** + +* [Tailscale](https://tailscale.com/) - Zero-config VPN mesh networking +* [ZeroTier](https://www.zerotier.com/) - Software-defined networking + +## Troubleshooting + +### Error: "SSL Certificate Private Key Exists but Cannot Be Accessed" + +**Cause:** Application lacks permissions to private key file. + +**Fix:** Run PowerShell script above to grant permissions. + +### Error: "The Remote Certificate Is Invalid According to the Validation Procedure" + +**Cause:** Certificate validation failed (expired, wrong CA, hostname mismatch). + +**Fix:** + +* Verify certificate is not expired: `Get-ChildItem Cert:\LocalMachine\My` +* Check certificate CN/SAN matches hostname +* For testing only: Set `suppress-validation = true` to identify if it's a validation issue + +### Error: "TLS Handshake Failed" with No Client Certificate + +**Cause:** Server requires mutual TLS but client didn't provide certificate. + +**Fix:** + +* Ensure all nodes have `require-mutual-authentication` set consistently +* Verify client certificate is configured correctly +* Check client application has private key access + +### Error: "RemoteCertificateNameMismatch" - Hostname Validation Failure + +**Full error message:** + +```text +TLS certificate validation failed (full validation): + - Certificate name mismatch + - RemoteCertificateNameMismatch: The hostname being connected to does not match + the hostname(s) on the server certificate. + +Certificate Details: + Subject: CN=node1.example.com + Issuer: CN=My-CA + Valid: 2025-01-01 to 2026-01-01 + +Connection target: 192.168.1.100:4053 +``` + +**Cause:** Certificate CN/SAN doesn't match the target hostname/IP address. + +**Common scenarios:** + +1. **Connecting via IP but certificate has DNS name** + * Connecting to: `192.168.1.100` + * Certificate CN: `node1.example.com` + +2. **Per-node certificates in P2P cluster** + * Node A cert CN: `node-a.cluster.local` + * Node B cert CN: `node-b.cluster.local` + * Each node's certificate doesn't match the other node's hostname + +**Fix:** + +Option 1 (Recommended for P2P clusters): Disable hostname validation + +```hocon +akka.remote.dot-netty.tcp.ssl { + validate-certificate-hostname = false # Allow per-node certs +} +``` + +Option 2: Use certificates with matching CN/SAN + +```bash +# Ensure certificate CN matches connection target +# For IP connections, add IP SAN to certificate: +New-SelfSignedCertificate -Subject "CN=node1" ` + -DnsName "node1", "node1.example.com" ` + -TextExtension @("2.5.29.17={text}IPAddress=192.168.1.100") +``` + +Option 3: Connect via DNS names that match certificate CN + +```hocon +akka.remote.dot-netty.tcp { + hostname = "node1.example.com" # Must match cert CN +} +``` + +### Error: "UntrustedRoot" - Certificate Chain Validation Failure + +**Full error message:** + +```text +TLS/SSL certificate validation failed: + - Certificate chain validation errors + - UntrustedRoot: A certificate chain processed, but terminated in a root + certificate which is not trusted by the trust provider. + +Certificate Details: + Subject: CN=localhost + Issuer: CN=localhost (self-signed) +``` + +**Cause:** Certificate is self-signed or signed by untrusted CA. + +**Fix:** + +Option 1 (Development only): Suppress chain validation + +```hocon +akka.remote.dot-netty.tcp.ssl { + suppress-validation = true # WARNING: Development only! +} +``` + +Option 2 (Recommended): Trust the CA certificate + +```powershell +# Windows: Import CA to Trusted Root store +Import-Certificate -FilePath ca.cer -CertStoreLocation Cert:\LocalMachine\Root + +# Linux: Add to system CA bundle +sudo cp ca.crt /usr/local/share/ca-certificates/ +sudo update-ca-certificates +``` + +### Understanding TLS Error Messages (v1.5.52+) + +Since v1.5.52, TLS handshake failures provide detailed diagnostic information including: + +* **Error category** (chain validation, hostname mismatch, etc.) +* **Specific SSL policy error** with explanation +* **Certificate details** (subject, issuer, validity period) +* **Connection context** (local/remote addresses) +* **Actionable recommendations** + +**Example comprehensive error:** + +```text +TLS handshake failed on channel [127.0.0.1:4053->127.0.0.1:54321](Id=...) + +Detailed TLS Error: + - Certificate chain validation errors + - UntrustedRoot: A certificate chain processed, but terminated in a root + certificate which is not trusted by the trust provider. + - Certificate name mismatch + - RemoteCertificateNameMismatch: The hostname being connected to does not + match the hostname(s) on the server certificate. + +Certificate Information: + Subject: CN=node-test + Issuer: CN=node-test (self-signed) + Serial Number: 1A2B3C4D5E6F + Valid From: 2025-01-01 00:00:00 UTC + Valid To: 2026-01-01 00:00:00 UTC + Thumbprint: 2531c78c51e5041d02564697a88af8bc7a7ce3e3 + +Recommendations: + - For development: Set 'suppress-validation = true' (testing only!) + - For production: Install certificate in trusted root store + - For hostname issues: Set 'validate-certificate-hostname = false' if using + per-node certificates or IP-based connections +``` + +## Additional Resources + +* [Windows Firewall Configuration Best Practices](https://learn.microsoft.com/en-us/windows/security/operating-system-security/network-security/windows-firewall/best-practices-configuring) +* [TLS 1.2 Specification (RFC 5246)](https://datatracker.ietf.org/doc/html/rfc5246) +* [OWASP Transport Layer Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Security_Cheat_Sheet.html) + +--- -Some options for doing this: +**Related:** -* [OpenVPN](https://openvpn.net/) - for "do it yourself" environments; -* [Azure Virtual Networks](http://azure.microsoft.com/en-us/services/virtual-network/) - for Windows Azure customers; and -* [Amazon Virtual Private Cloud (VPC)](http://aws.amazon.com/vpc/) - for Amazon Web Services customers. +* [Akka.Remote Configuration](xref:akka-remote-configuration) +* [DotNetty Transport](https://github.com/Azure/DotNetty) diff --git a/docs/cSpell.json b/docs/cSpell.json index 6a65e8176a9..706e9451c8b 100644 --- a/docs/cSpell.json +++ b/docs/cSpell.json @@ -36,6 +36,7 @@ "Hasher", "Hipsterize", "HOCON", + "hostnames", "journaled", "Kubernetes", "lifecycles", @@ -70,6 +71,7 @@ "Stannard", "substream", "substreams", + "Tailscale", "testkit", "threadedness", "threadpool", @@ -83,7 +85,8 @@ "userspace", "watchee", "Webcrawler", - "Xunit" + "Xunit", + "ZeroTier" ], "ignoreWords": [ "Hanselminutes", diff --git a/scripts/contributors.sh b/scripts/contributors.sh old mode 100644 new mode 100755 diff --git a/src/benchmark/Akka.Benchmarks/Logging/SemanticLoggingBenchmarks.cs b/src/benchmark/Akka.Benchmarks/Logging/SemanticLoggingBenchmarks.cs new file mode 100644 index 00000000000..614cd1b82be --- /dev/null +++ b/src/benchmark/Akka.Benchmarks/Logging/SemanticLoggingBenchmarks.cs @@ -0,0 +1,534 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2025 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using Akka.Benchmarks.Configurations; +using Akka.Event; +using BenchmarkDotNet.Attributes; +using static Akka.Benchmarks.Configurations.BenchmarkCategories; + +namespace Akka.Benchmarks.Logging +{ + /// + /// Benchmarks for semantic logging implementation in Akka.NET. + /// Tests template parsing, property extraction, and message formatting performance. + /// + /// Performance Targets: + /// - Template cache hit: <100ns + /// - Template parse (uncached): <5μs + /// - Full format operation: <2μs + /// - Property extraction: <1μs (with caching) + /// - GC pressure: <200 bytes per log call + /// + [Config(typeof(MicroBenchmarkConfig))] + [MemoryDiagnoser] + public class SemanticLoggingBenchmarks + { + // ============================================================================ + // CATEGORY 1: Template Parsing - Cache Performance + // ============================================================================ + + private const string SimpleTemplate = "User {UserId} logged in"; + private const string ComplexTemplate = "Request {RequestId} from {IpAddress} at {Timestamp:yyyy-MM-dd} returned {StatusCode} in {Duration:N2}ms"; + private const string PositionalTemplate = "Value {0} and {1} and {2}"; + + private string[] _varyingTemplates; + private const int TemplateVariations = 100; + + [GlobalSetup] + public void Setup() + { + // Pre-generate varying templates to test cache effectiveness + _varyingTemplates = new string[TemplateVariations]; + for (int i = 0; i < TemplateVariations; i++) + { + _varyingTemplates[i] = $"User {{UserId}} performed action {{Action{i}}}"; + } + + // Warm up the cache with first template + MessageTemplateParser.GetPropertyNames(SimpleTemplate); + } + + [Benchmark(Description = "Template parse - COLD (first time)")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyList TemplateParse_Cold() + { + // This simulates a cold cache by using a unique template each time + // Note: In reality this will pollute the cache, but shows worst-case + var template = $"Unique template {{Prop{Guid.NewGuid()}}}"; + return MessageTemplateParser.GetPropertyNames(template); + } + + [Benchmark(Description = "Template parse - WARM (cached)", Baseline = true)] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyList TemplateParse_Warm() + { + // Should hit ThreadStatic cache - target <100ns + return MessageTemplateParser.GetPropertyNames(SimpleTemplate); + } + + [Benchmark(Description = "Template parse - Complex template (cached)")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyList TemplateParse_ComplexCached() + { + return MessageTemplateParser.GetPropertyNames(ComplexTemplate); + } + + [Benchmark(Description = "Template parse - Positional {0} (cached)")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyList TemplateParse_PositionalCached() + { + return MessageTemplateParser.GetPropertyNames(PositionalTemplate); + } + + [Benchmark(Description = "Template parse - Cache thrashing (100 templates)")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyList TemplateParse_CacheThrashing() + { + // Tests LRU eviction by cycling through many templates + var result = default(IReadOnlyList); + for (int i = 0; i < TemplateVariations; i++) + { + result = MessageTemplateParser.GetPropertyNames(_varyingTemplates[i]); + } + return result; + } + + // ============================================================================ + // CATEGORY 2: Property Extraction - LogMessage Performance + // ============================================================================ + + private LogMessage _simpleLogMessage1Param; + private LogMessage _simpleLogMessage3Params; + private LogMessage _complexLogMessage5Params; + private LogMessage _positionalLogMessage; + + [GlobalSetup(Target = nameof(PropertyExtraction_1Param) + "," + + nameof(PropertyExtraction_3Params) + "," + + nameof(PropertyExtraction_5Params) + "," + + nameof(PropertyExtraction_Positional) + "," + + nameof(PropertyExtraction_Cached) + "," + + nameof(GetProperties_1Param) + "," + + nameof(GetProperties_3Params) + "," + + nameof(GetProperties_5Params) + "," + + nameof(GetProperties_Cached))] + public void SetupPropertyExtraction() + { + _simpleLogMessage1Param = new LogMessage>( + DefaultLogMessageFormatter.Instance, + "User {UserId} logged in", + new LogValues(12345) + ); + + _simpleLogMessage3Params = new LogMessage>( + DefaultLogMessageFormatter.Instance, + "User {UserId} from {IpAddress} at {Timestamp}", + new LogValues(12345, "192.168.1.1", DateTime.UtcNow) + ); + + _complexLogMessage5Params = new LogMessage>( + DefaultLogMessageFormatter.Instance, + ComplexTemplate, + new LogValues( + Guid.NewGuid(), "192.168.1.1", DateTime.UtcNow, 200, 123.45 + ) + ); + + _positionalLogMessage = new LogMessage>( + DefaultLogMessageFormatter.Instance, + "Value {0} and {1} and {2}", + new LogValues(42, "test", 3.14) + ); + } + + [Benchmark(Description = "PropertyNames - 1 param (lazy init)")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyList PropertyExtraction_1Param() + { + // Tests lazy initialization cost + var msg = new LogMessage>( + DefaultLogMessageFormatter.Instance, + SimpleTemplate, + new LogValues(12345) + ); + return msg.PropertyNames; + } + + [Benchmark(Description = "PropertyNames - 3 params (lazy init)")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyList PropertyExtraction_3Params() + { + var msg = new LogMessage>( + DefaultLogMessageFormatter.Instance, + "User {UserId} from {IpAddress} at {Timestamp}", + new LogValues(12345, "192.168.1.1", DateTime.UtcNow) + ); + return msg.PropertyNames; + } + + [Benchmark(Description = "PropertyNames - 5 params (lazy init)")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyList PropertyExtraction_5Params() + { + var msg = new LogMessage>( + DefaultLogMessageFormatter.Instance, + ComplexTemplate, + new LogValues( + Guid.NewGuid(), "192.168.1.1", DateTime.UtcNow, 200, 123.45 + ) + ); + return msg.PropertyNames; + } + + [Benchmark(Description = "PropertyNames - Positional")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyList PropertyExtraction_Positional() + { + var msg = new LogMessage>( + DefaultLogMessageFormatter.Instance, + PositionalTemplate, + new LogValues(42, "test", 3.14) + ); + return msg.PropertyNames; + } + + [Benchmark(Description = "PropertyNames - Cached (2nd access)")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyList PropertyExtraction_Cached() + { + // Should be cached after first access - target ~10ns + return _simpleLogMessage1Param.PropertyNames; + } + + // ============================================================================ + // CATEGORY 3: GetProperties() - Dictionary Construction + // ============================================================================ + + [Benchmark(Description = "GetProperties - 1 param")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyDictionary GetProperties_1Param() + { + return _simpleLogMessage1Param.GetProperties(); + } + + [Benchmark(Description = "GetProperties - 3 params")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyDictionary GetProperties_3Params() + { + return _simpleLogMessage3Params.GetProperties(); + } + + [Benchmark(Description = "GetProperties - 5 params")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyDictionary GetProperties_5Params() + { + return _complexLogMessage5Params.GetProperties(); + } + + [Benchmark(Description = "GetProperties - Cached (2nd access)")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyDictionary GetProperties_Cached() + { + // Should be cached - target ~5ns + return _simpleLogMessage1Param.GetProperties(); + } + + // ============================================================================ + // CATEGORY 4: Message Formatting - SemanticLogMessageFormatter vs Default + // ============================================================================ + + private object[] _args1 = new object[] { 12345 }; + private object[] _args3 = new object[] { 12345, "192.168.1.1", DateTime.UtcNow }; + private object[] _args5 = new object[] { Guid.NewGuid(), "192.168.1.1", DateTime.UtcNow, 200, 123.45 }; + + [Benchmark(Description = "Format - Semantic 1 param")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public string Format_Semantic_1Param() + { + return SemanticLogMessageFormatter.Instance.Format(SimpleTemplate, _args1); + } + + [Benchmark(Description = "Format - Semantic 3 params")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public string Format_Semantic_3Params() + { + return SemanticLogMessageFormatter.Instance.Format( + "User {UserId} from {IpAddress} at {Timestamp}", + _args3 + ); + } + + [Benchmark(Description = "Format - Semantic 5 params")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public string Format_Semantic_5Params() + { + return SemanticLogMessageFormatter.Instance.Format(ComplexTemplate, _args5); + } + + [Benchmark(Description = "Format - Semantic with format spec {Value:N2}")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public string Format_Semantic_WithFormatSpec() + { + return SemanticLogMessageFormatter.Instance.Format( + "Duration was {Duration:N2}ms", + new object[] { 123.456789 } + ); + } + + [Benchmark(Description = "Format - Default (positional) 3 params")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public string Format_Default_3Params() + { + return DefaultLogMessageFormatter.Instance.Format( + "Value {0} and {1} and {2}", + _args3 + ); + } + + [Benchmark(Description = "Format - Semantic Positional 3 params")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public string Format_Semantic_Positional_3Params() + { + return SemanticLogMessageFormatter.Instance.Format(PositionalTemplate, _args3); + } + + // ============================================================================ + // CATEGORY 5: End-to-End Logging Pipeline + // ============================================================================ + + private sealed class BenchmarkLogAdapter : LoggingAdapterBase + { + public LogEvent LastLog { get; private set; } + private readonly string _logSource; + private readonly Type _logClass; + + public BenchmarkLogAdapter(ILogMessageFormatter formatter) : base(formatter) + { + _logSource = LogSource.Create(this).Source; + _logClass = typeof(BenchmarkLogAdapter); + } + + public override bool IsDebugEnabled => true; + public override bool IsInfoEnabled => true; + public override bool IsWarningEnabled => true; + public override bool IsErrorEnabled => true; + + protected override void NotifyLog(LogLevel logLevel, object message, Exception cause = null) + { + LastLog = new Info(cause, _logSource, _logClass, message); + } + } + + private BenchmarkLogAdapter _defaultLogger; + private BenchmarkLogAdapter _semanticLogger; + + [GlobalSetup(Target = nameof(EndToEnd_Default_NoParams) + "," + + nameof(EndToEnd_Default_1Param) + "," + + nameof(EndToEnd_Default_3Params) + "," + + nameof(EndToEnd_Semantic_NoParams) + "," + + nameof(EndToEnd_Semantic_1Param) + "," + + nameof(EndToEnd_Semantic_3Params) + "," + + nameof(EndToEnd_Semantic_WithProperties))] + public void SetupEndToEnd() + { + _defaultLogger = new BenchmarkLogAdapter(DefaultLogMessageFormatter.Instance); + _semanticLogger = new BenchmarkLogAdapter(SemanticLogMessageFormatter.Instance); + } + + [Benchmark(Description = "E2E - Default formatter, no params")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public LogEvent EndToEnd_Default_NoParams() + { + _defaultLogger.Info("User logged in"); + return _defaultLogger.LastLog; + } + + [Benchmark(Description = "E2E - Default formatter, 1 param")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public LogEvent EndToEnd_Default_1Param() + { + _defaultLogger.Info("User {0} logged in", 12345); + return _defaultLogger.LastLog; + } + + [Benchmark(Description = "E2E - Default formatter, 3 params")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public LogEvent EndToEnd_Default_3Params() + { + _defaultLogger.Info("User {0} from {1} at {2}", 12345, "192.168.1.1", DateTime.UtcNow); + return _defaultLogger.LastLog; + } + + [Benchmark(Description = "E2E - Semantic formatter, no params")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public LogEvent EndToEnd_Semantic_NoParams() + { + _semanticLogger.Info("User logged in"); + return _semanticLogger.LastLog; + } + + [Benchmark(Description = "E2E - Semantic formatter, 1 param")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public LogEvent EndToEnd_Semantic_1Param() + { + _semanticLogger.Info("User {UserId} logged in", 12345); + return _semanticLogger.LastLog; + } + + [Benchmark(Description = "E2E - Semantic formatter, 3 params")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public LogEvent EndToEnd_Semantic_3Params() + { + _semanticLogger.Info("User {UserId} from {IpAddress} at {Timestamp}", + 12345, "192.168.1.1", DateTime.UtcNow); + return _semanticLogger.LastLog; + } + + [Benchmark(Description = "E2E - Semantic with GetProperties()")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyDictionary EndToEnd_Semantic_WithProperties() + { + _semanticLogger.Info("User {UserId} from {IpAddress}", 12345, "192.168.1.1"); + var logEvent = _semanticLogger.LastLog; + if (logEvent.TryGetProperties(out var props)) + return props; + return null; + } + + // ============================================================================ + // CATEGORY 6: Allocation Benchmarks - Memory Pressure Analysis + // ============================================================================ + + [Benchmark(Description = "Allocations - Parse template (cold)")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyList Allocations_ParseCold() + { + // Unique template to avoid cache + var template = $"Event {{Id}} at {{Time}} with {{Data}}"; + return MessageTemplateParser.GetPropertyNames(template); + } + + [Benchmark(Description = "Allocations - Format semantic 3 params")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public string Allocations_FormatSemantic() + { + return SemanticLogMessageFormatter.Instance.Format( + "User {UserId} from {IpAddress} at {Timestamp}", + new object[] { 12345, "192.168.1.1", DateTime.UtcNow } + ); + } + + [Benchmark(Description = "Allocations - GetProperties 3 params")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyDictionary Allocations_GetProperties() + { + var msg = new LogMessage>( + DefaultLogMessageFormatter.Instance, + "User {UserId} from {IpAddress} at {Timestamp}", + new LogValues(12345, "192.168.1.1", DateTime.UtcNow) + ); + return msg.GetProperties(); + } + + [Benchmark(Description = "Allocations - Full log + properties")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public IReadOnlyDictionary Allocations_FullPipeline() + { + _semanticLogger.Info("User {UserId} from {IpAddress} performed {Action}", + 12345, "192.168.1.1", "login"); + if (_semanticLogger.LastLog.TryGetProperties(out var props)) + return props; + return null; + } + + // ============================================================================ + // CATEGORY 7: Escaped Brace Handling + // ============================================================================ + + private const string EscapedBracesOnly = "Use {{ and }} for literals"; + private const string EscapedBracesWithPlaceholder = "{First}}} text {{more {Second}"; + private const string NestedEscapedBraces = "{{{UserId}}}"; + private const string TrailingEscapedBrace = "{UserId}}"; + + [Benchmark(Description = "Format - Escaped braces only (no placeholders)")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public string Format_EscapedBracesOnly() + { + // Tests UnescapeBraces path - no placeholders, just {{ and }} + return SemanticLogMessageFormatter.Instance.Format(EscapedBracesOnly, Array.Empty()); + } + + [Benchmark(Description = "Format - Escaped braces with placeholders")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public string Format_EscapedBracesWithPlaceholders() + { + // Tests FormatNamedTemplate with mixed escaped braces and placeholders + return SemanticLogMessageFormatter.Instance.Format( + EscapedBracesWithPlaceholder, + new object[] { 1, 2 } + ); + } + + [Benchmark(Description = "Format - Nested escaped braces {{{Value}}}")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public string Format_NestedEscapedBraces() + { + // Tests {{{ }}} pattern: escaped brace + placeholder + escaped brace + return SemanticLogMessageFormatter.Instance.Format(NestedEscapedBraces, new object[] { 123 }); + } + + [Benchmark(Description = "Format - Trailing escaped brace {Value}}")] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public string Format_TrailingEscapedBrace() + { + // Tests placeholder followed by literal } + return SemanticLogMessageFormatter.Instance.Format(TrailingEscapedBrace, new object[] { 123 }); + } + + // ============================================================================ + // CATEGORY 8: Stress Tests - Real-world Patterns + // ============================================================================ + + private const int BatchSize = 1000; + + [Benchmark(Description = "Stress - 1K logs (same template)", OperationsPerInvoke = BatchSize)] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public void Stress_1K_SameTemplate() + { + for (int i = 0; i < BatchSize; i++) + { + _semanticLogger.Info("User {UserId} logged in", i); + } + } + + [Benchmark(Description = "Stress - 1K logs (varying templates)", OperationsPerInvoke = BatchSize)] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public void Stress_1K_VaryingTemplates() + { + for (int i = 0; i < BatchSize; i++) + { + _semanticLogger.Info(_varyingTemplates[i % TemplateVariations], i); + } + } + + [Benchmark(Description = "Stress - 1K logs with property extraction", OperationsPerInvoke = BatchSize)] + [BenchmarkCategory(MicroBenchmark, AkkaEventBenchmark)] + public int Stress_1K_WithPropertyExtraction() + { + int propCount = 0; + for (int i = 0; i < BatchSize; i++) + { + _semanticLogger.Info("User {UserId} performed {Action}", i, $"Action{i}"); + if (_semanticLogger.LastLog.TryGetProperties(out var props)) + propCount += props.Count; + } + return propCount; + } + } +} diff --git a/src/contrib/cluster/Akka.Cluster.Sharding/ShardedDaemonProcess.cs b/src/contrib/cluster/Akka.Cluster.Sharding/ShardedDaemonProcess.cs index 9d8b3230d31..9b3ca930a0b 100644 --- a/src/contrib/cluster/Akka.Cluster.Sharding/ShardedDaemonProcess.cs +++ b/src/contrib/cluster/Akka.Cluster.Sharding/ShardedDaemonProcess.cs @@ -12,6 +12,7 @@ using Akka.Annotations; using Akka.Event; using Akka.Routing; +using Akka.Util; using Akka.Util.Internal; namespace Akka.Cluster.Sharding @@ -110,6 +111,9 @@ public DaemonMessageRouter(string[] entityIds, IActorRef shardingRef) if (_entityIds.Length == 0) throw new ArgumentException("At least one entityId must be provided", nameof(entityIds)); _shardingRef = shardingRef; + + // pick a random index to start with to avoid hot-spot formation + _index = ThreadLocalRandom.Current.Next(_entityIds.Length); } protected override void OnReceive(object message) diff --git a/src/contrib/cluster/Akka.DistributedData.Tests/LWWDictionarySpec.cs b/src/contrib/cluster/Akka.DistributedData.Tests/LWWDictionarySpec.cs index 0b659512566..127cf7d96fa 100644 --- a/src/contrib/cluster/Akka.DistributedData.Tests/LWWDictionarySpec.cs +++ b/src/contrib/cluster/Akka.DistributedData.Tests/LWWDictionarySpec.cs @@ -161,5 +161,33 @@ public async Task Bugfix_4400_LWWDictionary_Deltas_must_merge_other_LWWDictionar merged1.Entries["b"].Should().BeEquivalentTo("B2"); merged1.Entries["c"].Should().BeEquivalentTo("C"); } + + /// + /// Bug reproduction: https://github.com/akkadotnet/akka.net/issues/7910 + /// LWWDictionary.Delta should return null when underlying ORDictionary.Delta is null + /// + [Fact] + public void Bugfix_7910_LWWDictionary_Delta_should_handle_null_underlying_delta() + { + // Empty dictionary has no delta + var empty = LWWDictionary.Empty; + empty.Delta.Should().BeNull("empty dictionary should have null delta"); + + // After ResetDelta(), delta should be null + var m1 = LWWDictionary.Empty + .SetItem(_node1, "a", "A") + .SetItem(_node1, "b", "B"); + + m1.Delta.Should().NotBeNull("dictionary with modifications should have a delta"); + + var m2 = m1.ResetDelta(); + m2.Delta.Should().BeNull("after ResetDelta(), delta should be null"); + + // Verify the dictionary still contains the data + m2.ContainsKey("a").Should().BeTrue(); + m2.ContainsKey("b").Should().BeTrue(); + m2["a"].Should().Be("A"); + m2["b"].Should().Be("B"); + } } } diff --git a/src/contrib/cluster/Akka.DistributedData/LWWDictionary.cs b/src/contrib/cluster/Akka.DistributedData/LWWDictionary.cs index 5562e88dd53..b556b7554ba 100644 --- a/src/contrib/cluster/Akka.DistributedData/LWWDictionary.cs +++ b/src/contrib/cluster/Akka.DistributedData/LWWDictionary.cs @@ -402,8 +402,8 @@ public override int GetHashCode() } // TODO: optimize this so it doesn't allocate each time it's called - public ORDictionary>.IDeltaOperation Delta => - new LWWDictionaryDelta(Underlying.Delta); + public ORDictionary>.IDeltaOperation Delta => + Underlying.Delta == null ? null : new LWWDictionaryDelta(Underlying.Delta); IReplicatedDelta IDeltaReplicatedData.Delta => Delta; diff --git a/src/contrib/cluster/Akka.DistributedData/Replicator.cs b/src/contrib/cluster/Akka.DistributedData/Replicator.cs index 1fc40c852a6..c180ee03921 100644 --- a/src/contrib/cluster/Akka.DistributedData/Replicator.cs +++ b/src/contrib/cluster/Akka.DistributedData/Replicator.cs @@ -1240,7 +1240,7 @@ private void ReceiveUnsubscribe(IKey key, IActorRef subscriber) if (!HasSubscriber(subscriber)) Context.Unwatch(subscriber); - if (!_subscribers.ContainsKey(key.Id) || !_newSubscribers.ContainsKey(key.Id)) + if (!_subscribers.ContainsKey(key.Id) && !_newSubscribers.ContainsKey(key.Id)) _subscriptionKeys = _subscriptionKeys.Remove(key.Id); } diff --git a/src/contrib/testkits/Akka.TestKit.Xunit/Internals/Loggers.cs b/src/contrib/testkits/Akka.TestKit.Xunit/Internals/Loggers.cs index a95e352c646..a66ec1a3dc3 100644 --- a/src/contrib/testkits/Akka.TestKit.Xunit/Internals/Loggers.cs +++ b/src/contrib/testkits/Akka.TestKit.Xunit/Internals/Loggers.cs @@ -35,6 +35,8 @@ public TestOutputLogger(ITestOutputHelper output) Receive(e => { e.LoggingBus.Subscribe(Self, typeof (LogEvent)); + // Send response to maintain protocol - LoggerInitialized implements IDeadLetterSuppression + // so it won't interfere with dead letter detection or TestActor message expectations Sender.Tell(new LoggerInitialized()); }); } diff --git a/src/contrib/testkits/Akka.TestKit.Xunit/TestKit.cs b/src/contrib/testkits/Akka.TestKit.Xunit/TestKit.cs index 26272f21045..f81c18b39ca 100644 --- a/src/contrib/testkits/Akka.TestKit.Xunit/TestKit.cs +++ b/src/contrib/testkits/Akka.TestKit.Xunit/TestKit.cs @@ -7,6 +7,7 @@ using System; using Akka.Actor; +using Akka.Actor.Internal; using Akka.Actor.Setup; using Akka.Configuration; using Akka.Event; @@ -170,10 +171,19 @@ protected void InitializeLogger(ActorSystem system) if (Output == null) return; - var extSystem = (ExtendedActorSystem)system; - var logger = extSystem.SystemActorOf(Props.Create(() => new TestOutputLogger(Output)), "log-test"); - logger.Ask(new InitializeLogger(system.EventStream), TestKitSettings.TestKitStartupTimeout) - .ConfigureAwait(false).GetAwaiter().GetResult(); + var systemImpl = system as ActorSystemImpl ?? throw new InvalidOperationException("Expected ActorSystemImpl"); + + // Create logger actor synchronously to avoid deadlock during parallel test execution + // Use AttachChildWithAsync with isAsync:false to create LocalActorRef instead of RepointableActorRef + var logger = systemImpl.Provider.SystemGuardian.Cell.AttachChildWithAsync( + Props.Create(() => new TestOutputLogger(Output)), + isSystemService: true, // Mark as system service + isAsync: false, // Create synchronously to avoid deadlock + name: "log-test"); + + // Send the initialization message without waiting for response to avoid deadlock + // The logger will subscribe to the event stream when it processes this message + logger.Tell(new InitializeLogger(system.EventStream), ActorRefs.NoSender); } protected void InitializeLogger(ActorSystem system, string prefix) @@ -181,11 +191,19 @@ protected void InitializeLogger(ActorSystem system, string prefix) if (Output == null) return; - var extSystem = (ExtendedActorSystem)system; - var logger = extSystem.SystemActorOf(Props.Create(() => new TestOutputLogger( - string.IsNullOrEmpty(prefix) ? Output : new PrefixedOutput(Output, prefix))), "log-test"); - logger.Ask(new InitializeLogger(system.EventStream), TestKitSettings.TestKitStartupTimeout) - .ConfigureAwait(false).GetAwaiter().GetResult(); + var systemImpl = system as ActorSystemImpl ?? throw new InvalidOperationException("Expected ActorSystemImpl"); + + // Create logger actor synchronously to avoid deadlock during parallel test execution + var logger = systemImpl.Provider.SystemGuardian.Cell.AttachChildWithAsync( + Props.Create(() => new TestOutputLogger( + string.IsNullOrEmpty(prefix) ? Output : new PrefixedOutput(Output, prefix))), + isSystemService: true, // Mark as system service + isAsync: false, // Create synchronously to avoid deadlock + name: "log-test"); + + // Send the initialization message without waiting for response to avoid deadlock + // The logger will subscribe to the event stream when it processes this message + logger.Tell(new InitializeLogger(system.EventStream), ActorRefs.NoSender); } /// diff --git a/src/contrib/testkits/Akka.TestKit.Xunit2/Internals/Loggers.cs b/src/contrib/testkits/Akka.TestKit.Xunit2/Internals/Loggers.cs index 903c6e0fc99..0b6413bfc2a 100644 --- a/src/contrib/testkits/Akka.TestKit.Xunit2/Internals/Loggers.cs +++ b/src/contrib/testkits/Akka.TestKit.Xunit2/Internals/Loggers.cs @@ -35,6 +35,8 @@ public TestOutputLogger(ITestOutputHelper output) Receive(e => { e.LoggingBus.Subscribe(Self, typeof (LogEvent)); + // Send response to maintain protocol - LoggerInitialized implements IDeadLetterSuppression + // so it won't interfere with dead letter detection or TestActor message expectations Sender.Tell(new LoggerInitialized()); }); } diff --git a/src/contrib/testkits/Akka.TestKit.Xunit2/TestKit.cs b/src/contrib/testkits/Akka.TestKit.Xunit2/TestKit.cs index 4f73708548f..3af97e60445 100644 --- a/src/contrib/testkits/Akka.TestKit.Xunit2/TestKit.cs +++ b/src/contrib/testkits/Akka.TestKit.Xunit2/TestKit.cs @@ -8,6 +8,7 @@ using System; using System.Threading.Tasks; using Akka.Actor; +using Akka.Actor.Internal; using Akka.Actor.Setup; using Akka.Configuration; using Akka.Event; @@ -140,14 +141,19 @@ protected void InitializeLogger(ActorSystem system) { if (Output != null) { - var extSystem = (ExtendedActorSystem)system; - var logger = extSystem.SystemActorOf(Props.Create(() => new TestOutputLogger(Output)), "log-test"); - // Start the logger initialization task but don't wait for it yet - var loggerTask = logger.Ask(new InitializeLogger(system.EventStream), TestKitSettings.TestKitStartupTimeout); + var systemImpl = system as ActorSystemImpl ?? throw new InvalidOperationException("Expected ActorSystemImpl"); - // By the time TestActor is ready (which happens in base constructor), - // the logger is likely ready too. Now we can safely wait. - loggerTask.ConfigureAwait(false).GetAwaiter().GetResult(); + // Create logger actor synchronously to avoid deadlock during parallel test execution + // Use AttachChildWithAsync with isAsync:false to create LocalActorRef instead of RepointableActorRef + var logger = systemImpl.Provider.SystemGuardian.Cell.AttachChildWithAsync( + Props.Create(() => new TestOutputLogger(Output)), + isSystemService: true, // Mark as system service + isAsync: false, // Create synchronously to avoid deadlock + name: "log-test"); + + // Send the initialization message without waiting for response to avoid deadlock + // The logger will subscribe to the event stream when it processes this message + logger.Tell(new InitializeLogger(system.EventStream), ActorRefs.NoSender); } } @@ -155,15 +161,19 @@ protected void InitializeLogger(ActorSystem system, string prefix) { if (Output != null) { - var extSystem = (ExtendedActorSystem)system; - var logger = extSystem.SystemActorOf(Props.Create(() => new TestOutputLogger( - string.IsNullOrEmpty(prefix) ? Output : new PrefixedOutput(Output, prefix))), "log-test"); - // Start the logger initialization task but don't wait for it yet - var loggerTask = logger.Ask(new InitializeLogger(system.EventStream), TestKitSettings.TestKitStartupTimeout); + var systemImpl = system as ActorSystemImpl ?? throw new InvalidOperationException("Expected ActorSystemImpl"); + + // Create logger actor synchronously to avoid deadlock during parallel test execution + var logger = systemImpl.Provider.SystemGuardian.Cell.AttachChildWithAsync( + Props.Create(() => new TestOutputLogger( + string.IsNullOrEmpty(prefix) ? Output : new PrefixedOutput(Output, prefix))), + isSystemService: true, // Mark as system service + isAsync: false, // Create synchronously to avoid deadlock + name: "log-test"); - // By the time TestActor is ready (which happens in base constructor), - // the logger is likely ready too. Now we can safely wait. - loggerTask.ConfigureAwait(false).GetAwaiter().GetResult(); + // Send the initialization message without waiting for response to avoid deadlock + // The logger will subscribe to the event stream when it processes this message + logger.Tell(new InitializeLogger(system.EventStream), ActorRefs.NoSender); } } diff --git a/src/core/Akka.API.Tests/LogFormatSpec.cs b/src/core/Akka.API.Tests/LogFormatSpec.cs index 5775ffcbc1e..bda336d43ef 100644 --- a/src/core/Akka.API.Tests/LogFormatSpec.cs +++ b/src/core/Akka.API.Tests/LogFormatSpec.cs @@ -32,9 +32,9 @@ public DefaultLogFormatSpec() : base(CustomLoggerSetup()) { _logger = (CustomLogger)Sys.Settings.StdoutLogger; } - + private readonly CustomLogger _logger; - + public class CustomLogger : StandardOutLogger { protected override void Log(object message) @@ -44,13 +44,13 @@ protected override void Log(object message) { _events.Add(e); } - + } - + private readonly ConcurrentBag _events = new(); public IReadOnlyCollection Events => _events; } - + public static ActorSystemSetup CustomLoggerSetup() { var hocon = @$" @@ -109,10 +109,76 @@ await AwaitConditionAsync(() => text = SanitizeThreadNumber(text); // to resolve https://github.com/akkadotnet/akka.net/issues/7421 text = SanitizeTestEventListener(text); - + await Verifier.Verify(text); } - + + [Fact] + public async Task ShouldHandleSemanticLogEdgeCases() + { + // arrange + var filePath = Path.GetTempFileName(); + + // act + using (new OutputRedirector(filePath)) + { + // Named properties + Sys.Log.Debug("User {UserId} logged in from {IpAddress}", 12345, "192.168.1.1"); + Sys.Log.Info("Processing order {OrderId} for customer {CustomerId}", "ORD-001", "CUST-999"); + + // Positional properties (old style) + Sys.Log.Warning("Processing item {0} of {1}", 5, 10); + + // Mixed types - use F2 instead of C for culture-independent output + Sys.Log.Info("Order total is ${Amount:F2} with {ItemCount} items", 123.45m, 3); + + // Edge cases + Sys.Log.Debug("Empty template"); + Sys.Log.Info("Single property {Value}", 42); + Sys.Log.Warning("Null value: {NullValue}", null); + Sys.Log.Error("Exception occurred for user {UserId}", 999); + + // Special characters and escaping + Sys.Log.Debug("Path: {FilePath}, Size: {FileSize} bytes", @"C:\temp\file.txt", 1024); + + // Boolean and date types - use explicit date format for culture-independent output + Sys.Log.Info("User {Username} is active: {IsActive}, joined on {JoinDate:yyyy-MM-dd}", "john.doe", true, DateTime.Parse("2024-01-15")); + + // Long strings and alignment + Sys.Log.Debug("Request from {RemoteAddress} to endpoint {Endpoint} took {DurationMs}ms", "192.168.1.100:54321", "/api/v1/users", 250); + + // force all logs to be received - wait for the last log message + await AwaitConditionAsync(() => Task.FromResult(_logger.Events.Any(e => e.Message.ToString()!.Contains("took 250ms"))), TimeSpan.FromSeconds(5)); + } + + // assert + // ReSharper disable once MethodHasAsyncOverload + var text = File.ReadAllText(filePath); + + // need to sanitize the thread id and timestamps + text = SanitizeDateTime(text); + text = SanitizeThreadNumber(text); + text = SanitizeTestEventListener(text); + text = SanitizeDefaultLoggersStarted(text); + text = SanitizeCustomLoggerRemoved(text); + + await Verifier.Verify(text); + } + + private static string SanitizeDefaultLoggersStarted(string logs) + { + var pattern = @"^.*Default Loggers started.*$\r?\n?"; + var result = Regex.Replace(logs, pattern, string.Empty, RegexOptions.Multiline); + return result; + } + + private static string SanitizeCustomLoggerRemoved(string logs) + { + var pattern = @"^.*CustomLogger being removed.*$\r?\n?"; + var result = Regex.Replace(logs, pattern, string.Empty, RegexOptions.Multiline); + return result; + } + private static string SanitizeTestEventListener(string logs) { var pattern = @"^.*Akka\.TestKit\.TestEventListener.*$"; diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt index 7d8f3de5a78..0e36be385d2 100644 --- a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt @@ -27,6 +27,8 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.Streams.Tests")] [assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.TestKit")] [assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.TestKit.Tests")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.TestKit.Xunit")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.TestKit.Xunit2")] [assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.Tests")] [assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.Tests.Performance")] [assembly: System.Runtime.InteropServices.ComVisibleAttribute(false)] @@ -3481,6 +3483,17 @@ namespace Akka.Event public abstract Akka.Event.LogLevel LogLevel(); public override string ToString() { } } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public class static LogEventExtensions + { + public static System.Collections.Generic.IEnumerable GetParameters(this Akka.Event.LogEvent evt) { } + public static System.Collections.Generic.IReadOnlyList GetPropertyNames(this Akka.Event.LogEvent evt) { } + public static string GetTemplate(this Akka.Event.LogEvent evt) { } + public static bool TryGetProperties(this Akka.Event.LogEvent evt, [System.Runtime.CompilerServices.NullableAttribute(new byte[] { + 2, + 1, + 1})] out System.Collections.Generic.IReadOnlyDictionary properties) { } + } public abstract class LogFilterBase : Akka.Actor.INoSerializationVerificationNeeded, Akka.Event.IDeadLetterSuppression { protected LogFilterBase() { } @@ -3540,6 +3553,8 @@ namespace Akka.Event protected readonly Akka.Event.ILogMessageFormatter Formatter; public LogMessage(Akka.Event.ILogMessageFormatter formatter, string format) { } public string Format { get; } + public System.Collections.Generic.IReadOnlyList PropertyNames { get; } + public System.Collections.Generic.IReadOnlyDictionary GetProperties() { } [Akka.Annotations.InternalApiAttribute()] public abstract System.Collections.Generic.IEnumerable Parameters(); [Akka.Annotations.InternalApiAttribute()] @@ -3717,6 +3732,12 @@ namespace Akka.Event public override Akka.Event.LogFilterType FilterType { get; } public override Akka.Event.LogFilterDecision ShouldKeepMessage(Akka.Event.LogEvent content, [System.Runtime.CompilerServices.NullableAttribute(2)] string expandedMessage = null) { } } + public sealed class SemanticLogMessageFormatter : Akka.Event.ILogMessageFormatter + { + public static readonly Akka.Event.SemanticLogMessageFormatter Instance; + public string Format(string format, params object[] args) { } + public string Format(string format, System.Collections.Generic.IEnumerable args) { } + } public class StandardOutLogger : Akka.Event.MinimalLogger { public StandardOutLogger() { } diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.Net.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.Net.verified.txt index 02e6be3fb03..69fcd307543 100644 --- a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.Net.verified.txt +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveCore.Net.verified.txt @@ -27,6 +27,8 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.Streams.Tests")] [assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.TestKit")] [assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.TestKit.Tests")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.TestKit.Xunit")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.TestKit.Xunit2")] [assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.Tests")] [assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.Tests.Performance")] [assembly: System.Runtime.InteropServices.ComVisibleAttribute(false)] @@ -3472,6 +3474,17 @@ namespace Akka.Event public abstract Akka.Event.LogLevel LogLevel(); public override string ToString() { } } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public class static LogEventExtensions + { + public static System.Collections.Generic.IEnumerable GetParameters(this Akka.Event.LogEvent evt) { } + public static System.Collections.Generic.IReadOnlyList GetPropertyNames(this Akka.Event.LogEvent evt) { } + public static string GetTemplate(this Akka.Event.LogEvent evt) { } + public static bool TryGetProperties(this Akka.Event.LogEvent evt, [System.Runtime.CompilerServices.NullableAttribute(new byte[] { + 2, + 1, + 1})] out System.Collections.Generic.IReadOnlyDictionary properties) { } + } public abstract class LogFilterBase : Akka.Actor.INoSerializationVerificationNeeded, Akka.Event.IDeadLetterSuppression { protected LogFilterBase() { } @@ -3531,6 +3544,8 @@ namespace Akka.Event protected readonly Akka.Event.ILogMessageFormatter Formatter; public LogMessage(Akka.Event.ILogMessageFormatter formatter, string format) { } public string Format { get; } + public System.Collections.Generic.IReadOnlyList PropertyNames { get; } + public System.Collections.Generic.IReadOnlyDictionary GetProperties() { } [Akka.Annotations.InternalApiAttribute()] public abstract System.Collections.Generic.IEnumerable Parameters(); [Akka.Annotations.InternalApiAttribute()] @@ -3706,6 +3721,12 @@ namespace Akka.Event public override Akka.Event.LogFilterType FilterType { get; } public override Akka.Event.LogFilterDecision ShouldKeepMessage(Akka.Event.LogEvent content, [System.Runtime.CompilerServices.NullableAttribute(2)] string expandedMessage = null) { } } + public sealed class SemanticLogMessageFormatter : Akka.Event.ILogMessageFormatter + { + public static readonly Akka.Event.SemanticLogMessageFormatter Instance; + public string Format(string format, params object[] args) { } + public string Format(string format, System.Collections.Generic.IEnumerable args) { } + } public class StandardOutLogger : Akka.Event.MinimalLogger { public StandardOutLogger() { } diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApprovePersistence.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApprovePersistence.DotNet.verified.txt index b6a437a6d2b..9150919ff0a 100644 --- a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApprovePersistence.DotNet.verified.txt +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApprovePersistence.DotNet.verified.txt @@ -7,12 +7,6 @@ [assembly: System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v6.0", FrameworkDisplayName=".NET 6.0")] namespace Akka.Persistence { - public sealed class AsyncHandlerInvocation : Akka.Persistence.IPendingHandlerInvocation - { - public AsyncHandlerInvocation(object evt, System.Action handler) { } - public object Event { get; } - public System.Action Handler { get; } - } public abstract class AtLeastOnceDeliveryActor : Akka.Persistence.PersistentActor { protected AtLeastOnceDeliveryActor() { } @@ -113,6 +107,18 @@ namespace Akka.Persistence public override int GetHashCode() { } public override string ToString() { } } + public sealed class CheckJournalHealth : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IJournalMessage, Akka.Persistence.IJournalRequest, Akka.Persistence.IPersistenceMessage + { + public CheckJournalHealth(System.Threading.CancellationToken cancellationToken) { } + public System.Threading.CancellationToken CancellationToken { get; } + public override string ToString() { } + } + public sealed class CheckSnapshotStoreHealth : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IPersistenceMessage, Akka.Persistence.ISnapshotMessage, Akka.Persistence.ISnapshotRequest + { + public CheckSnapshotStoreHealth(System.Threading.CancellationToken cancellationToken) { } + public System.Threading.CancellationToken CancellationToken { get; } + public override string ToString() { } + } public sealed class DeleteMessagesFailure : Akka.Actor.INoSerializationVerificationNeeded, System.IEquatable { public DeleteMessagesFailure(System.Exception cause, long toSequenceNr) { } @@ -234,6 +240,7 @@ namespace Akka.Persistence public override void AroundPreStart() { } protected override bool AroundReceive(Akka.Actor.Receive receive, object message) { } public void DeferAsync(TEvent evt, System.Action handler) { } + public void DeferAsync(TEvent evt, System.Func handler) { } public void DeleteMessages(long toSequenceNr) { } public void DeleteSnapshot(long sequenceNr) { } public void DeleteSnapshots(Akka.Persistence.SnapshotSelectionCriteria criteria) { } @@ -243,9 +250,21 @@ namespace Akka.Persistence protected virtual void OnRecoveryFailure(System.Exception reason, object message = null) { } protected virtual void OnReplaySuccess() { } public void Persist(TEvent @event, System.Action handler) { } + public void Persist(TEvent @event, System.Func handler) { } public void PersistAll(System.Collections.Generic.IEnumerable events, System.Action handler) { } + public void PersistAll(System.Collections.Generic.IEnumerable events, System.Action handler, System.Action onComplete) { } + public void PersistAll(System.Collections.Generic.IEnumerable events, System.Action handler, System.Func onCompleteAsync) { } + public void PersistAll(System.Collections.Generic.IEnumerable events, System.Func handler) { } + public void PersistAll(System.Collections.Generic.IEnumerable events, System.Func handler, System.Action onComplete) { } + public void PersistAll(System.Collections.Generic.IEnumerable events, System.Func handler, System.Func onCompleteAsync) { } public void PersistAllAsync(System.Collections.Generic.IEnumerable events, System.Action handler) { } + public void PersistAllAsync(System.Collections.Generic.IEnumerable events, System.Action handler, System.Action onComplete) { } + public void PersistAllAsync(System.Collections.Generic.IEnumerable events, System.Action handler, System.Func onCompleteAsync) { } + public void PersistAllAsync(System.Collections.Generic.IEnumerable events, System.Func handler) { } + public void PersistAllAsync(System.Collections.Generic.IEnumerable events, System.Func handler, System.Action onComplete) { } + public void PersistAllAsync(System.Collections.Generic.IEnumerable events, System.Func handler, System.Func onCompleteAsync) { } public void PersistAsync(TEvent @event, System.Action handler) { } + public void PersistAsync(TEvent @event, System.Func handler) { } protected abstract bool ReceiveCommand(object message); protected abstract bool ReceiveRecover(object message); protected void RunTask(System.Func action) { } @@ -260,11 +279,6 @@ namespace Akka.Persistence } public interface IJournalRequest : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IJournalMessage, Akka.Persistence.IPersistenceMessage { } public interface IJournalResponse : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IJournalMessage, Akka.Persistence.IPersistenceMessage { } - public interface IPendingHandlerInvocation - { - object Event { get; } - System.Action Handler { get; } - } public interface IPersistenceMessage : Akka.Actor.INoSerializationVerificationNeeded { } public interface IPersistenceRecovery { @@ -318,6 +332,12 @@ namespace Akka.Persistence { Akka.Persistence.IStashOverflowStrategy Create(Akka.Configuration.Config config); } + public sealed class JournalHealthCheckResponse : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IJournalMessage, Akka.Persistence.IJournalResponse, Akka.Persistence.IPersistenceMessage + { + public JournalHealthCheckResponse(Akka.Persistence.PersistenceHealthCheckResult result) { } + public Akka.Persistence.PersistenceHealthCheckResult Result { get; } + public override string ToString() { } + } public sealed class LoadSnapshot : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IPersistenceMessage, Akka.Persistence.ISnapshotMessage, Akka.Persistence.ISnapshotRequest, System.IEquatable { public LoadSnapshot(string persistenceId, Akka.Persistence.SnapshotSelectionCriteria criteria, long toSequenceNr) { } @@ -377,12 +397,37 @@ namespace Akka.Persistence public Akka.Persistence.IStashOverflowStrategy DefaultInternalStashOverflowStrategy { get; } public Akka.Persistence.PersistenceSettings Settings { get; } public Akka.Persistence.Journal.EventAdapters AdaptersFor(string journalPluginId) { } + public System.Threading.Tasks.Task CheckJournalHealthAsync(string journalPluginId, System.Threading.CancellationToken cancellationToken = null) { } + public System.Threading.Tasks.Task CheckSnapshotStoreHealthAsync(string snapshotStorePluginId, System.Threading.CancellationToken cancellationToken = null) { } [Akka.Annotations.InternalStableApiAttribute()] public Akka.Actor.IActorRef JournalFor(string journalPluginId) { } public string PersistenceId(Akka.Actor.IActorRef actor) { } [Akka.Annotations.InternalStableApiAttribute()] public Akka.Actor.IActorRef SnapshotStoreFor(string snapshotPluginId) { } } + [System.Runtime.CompilerServices.IsReadOnlyAttribute()] + [System.Runtime.CompilerServices.NullableAttribute(0)] + public struct PersistenceHealthCheckResult : System.IEquatable + { + public PersistenceHealthCheckResult(Akka.Persistence.PersistenceHealthStatus Status, string Description = null, System.Exception Exception = null, [System.Runtime.CompilerServices.NullableAttribute(new byte[] { + 2, + 0, + 0})] System.Collections.Generic.IReadOnlyDictionary Data = null) { } + [System.Runtime.CompilerServices.NullableAttribute(new byte[] { + 2, + 0, + 0})] + public System.Collections.Generic.IReadOnlyDictionary Data { get; set; } + public string Description { get; set; } + public System.Exception Exception { get; set; } + public Akka.Persistence.PersistenceHealthStatus Status { get; set; } + } + public enum PersistenceHealthStatus + { + Healthy = 0, + Degraded = 1, + Unhealthy = 2, + } public sealed class PersistenceSettings : Akka.Actor.Settings { public PersistenceSettings(Akka.Actor.ActorSystem system, Akka.Configuration.Config config) { } @@ -627,11 +672,11 @@ namespace Akka.Persistence public override int GetHashCode() { } public override string ToString() { } } - public sealed class StashingHandlerInvocation : Akka.Persistence.IPendingHandlerInvocation + public sealed class SnapshotStoreHealthCheckResponse : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IPersistenceMessage, Akka.Persistence.ISnapshotMessage, Akka.Persistence.ISnapshotResponse { - public StashingHandlerInvocation(object evt, System.Action handler) { } - public object Event { get; } - public System.Action Handler { get; } + public SnapshotStoreHealthCheckResponse(Akka.Persistence.PersistenceHealthCheckResult result) { } + public Akka.Persistence.PersistenceHealthCheckResult Result { get; } + public override string ToString() { } } public sealed class ThrowExceptionConfigurator : Akka.Persistence.IStashOverflowStrategyConfigurator { @@ -857,6 +902,7 @@ namespace Akka.Persistence.Journal { protected readonly bool CanPublish; protected AsyncWriteJournal() { } + public virtual System.Threading.Tasks.Task CheckHealthAsync(System.Threading.CancellationToken cancellationToken = null) { } protected abstract System.Threading.Tasks.Task DeleteMessagesToAsync(string persistenceId, long toSequenceNr, System.Threading.CancellationToken cancellationToken); public abstract System.Threading.Tasks.Task ReadHighestSequenceNrAsync(string persistenceId, long fromSequenceNr, System.Threading.CancellationToken cancellationToken); protected virtual bool Receive(object message) { } @@ -1215,6 +1261,7 @@ namespace Akka.Persistence.Snapshot public abstract class SnapshotStore : Akka.Actor.ActorBase { protected SnapshotStore() { } + public virtual System.Threading.Tasks.Task CheckHealthAsync(System.Threading.CancellationToken cancellationToken = null) { } protected abstract System.Threading.Tasks.Task DeleteAsync(Akka.Persistence.SnapshotMetadata metadata, System.Threading.CancellationToken cancellationToken); protected abstract System.Threading.Tasks.Task DeleteAsync(string persistenceId, Akka.Persistence.SnapshotSelectionCriteria criteria, System.Threading.CancellationToken cancellationToken); protected abstract System.Threading.Tasks.Task LoadAsync(string persistenceId, Akka.Persistence.SnapshotSelectionCriteria criteria, System.Threading.CancellationToken cancellationToken); diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApprovePersistence.Net.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApprovePersistence.Net.verified.txt index 5b8a811b2bb..bc49f0158d3 100644 --- a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApprovePersistence.Net.verified.txt +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApprovePersistence.Net.verified.txt @@ -7,12 +7,6 @@ [assembly: System.Runtime.Versioning.TargetFrameworkAttribute(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")] namespace Akka.Persistence { - public sealed class AsyncHandlerInvocation : Akka.Persistence.IPendingHandlerInvocation - { - public AsyncHandlerInvocation(object evt, System.Action handler) { } - public object Event { get; } - public System.Action Handler { get; } - } public abstract class AtLeastOnceDeliveryActor : Akka.Persistence.PersistentActor { protected AtLeastOnceDeliveryActor() { } @@ -113,6 +107,18 @@ namespace Akka.Persistence public override int GetHashCode() { } public override string ToString() { } } + public sealed class CheckJournalHealth : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IJournalMessage, Akka.Persistence.IJournalRequest, Akka.Persistence.IPersistenceMessage + { + public CheckJournalHealth(System.Threading.CancellationToken cancellationToken) { } + public System.Threading.CancellationToken CancellationToken { get; } + public override string ToString() { } + } + public sealed class CheckSnapshotStoreHealth : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IPersistenceMessage, Akka.Persistence.ISnapshotMessage, Akka.Persistence.ISnapshotRequest + { + public CheckSnapshotStoreHealth(System.Threading.CancellationToken cancellationToken) { } + public System.Threading.CancellationToken CancellationToken { get; } + public override string ToString() { } + } public sealed class DeleteMessagesFailure : Akka.Actor.INoSerializationVerificationNeeded, System.IEquatable { public DeleteMessagesFailure(System.Exception cause, long toSequenceNr) { } @@ -234,6 +240,7 @@ namespace Akka.Persistence public override void AroundPreStart() { } protected override bool AroundReceive(Akka.Actor.Receive receive, object message) { } public void DeferAsync(TEvent evt, System.Action handler) { } + public void DeferAsync(TEvent evt, System.Func handler) { } public void DeleteMessages(long toSequenceNr) { } public void DeleteSnapshot(long sequenceNr) { } public void DeleteSnapshots(Akka.Persistence.SnapshotSelectionCriteria criteria) { } @@ -243,9 +250,21 @@ namespace Akka.Persistence protected virtual void OnRecoveryFailure(System.Exception reason, object message = null) { } protected virtual void OnReplaySuccess() { } public void Persist(TEvent @event, System.Action handler) { } + public void Persist(TEvent @event, System.Func handler) { } public void PersistAll(System.Collections.Generic.IEnumerable events, System.Action handler) { } + public void PersistAll(System.Collections.Generic.IEnumerable events, System.Action handler, System.Action onComplete) { } + public void PersistAll(System.Collections.Generic.IEnumerable events, System.Action handler, System.Func onCompleteAsync) { } + public void PersistAll(System.Collections.Generic.IEnumerable events, System.Func handler) { } + public void PersistAll(System.Collections.Generic.IEnumerable events, System.Func handler, System.Action onComplete) { } + public void PersistAll(System.Collections.Generic.IEnumerable events, System.Func handler, System.Func onCompleteAsync) { } public void PersistAllAsync(System.Collections.Generic.IEnumerable events, System.Action handler) { } + public void PersistAllAsync(System.Collections.Generic.IEnumerable events, System.Action handler, System.Action onComplete) { } + public void PersistAllAsync(System.Collections.Generic.IEnumerable events, System.Action handler, System.Func onCompleteAsync) { } + public void PersistAllAsync(System.Collections.Generic.IEnumerable events, System.Func handler) { } + public void PersistAllAsync(System.Collections.Generic.IEnumerable events, System.Func handler, System.Action onComplete) { } + public void PersistAllAsync(System.Collections.Generic.IEnumerable events, System.Func handler, System.Func onCompleteAsync) { } public void PersistAsync(TEvent @event, System.Action handler) { } + public void PersistAsync(TEvent @event, System.Func handler) { } protected abstract bool ReceiveCommand(object message); protected abstract bool ReceiveRecover(object message); protected void RunTask(System.Func action) { } @@ -260,11 +279,6 @@ namespace Akka.Persistence } public interface IJournalRequest : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IJournalMessage, Akka.Persistence.IPersistenceMessage { } public interface IJournalResponse : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IJournalMessage, Akka.Persistence.IPersistenceMessage { } - public interface IPendingHandlerInvocation - { - object Event { get; } - System.Action Handler { get; } - } public interface IPersistenceMessage : Akka.Actor.INoSerializationVerificationNeeded { } public interface IPersistenceRecovery { @@ -318,6 +332,12 @@ namespace Akka.Persistence { Akka.Persistence.IStashOverflowStrategy Create(Akka.Configuration.Config config); } + public sealed class JournalHealthCheckResponse : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IJournalMessage, Akka.Persistence.IJournalResponse, Akka.Persistence.IPersistenceMessage + { + public JournalHealthCheckResponse(Akka.Persistence.PersistenceHealthCheckResult result) { } + public Akka.Persistence.PersistenceHealthCheckResult Result { get; } + public override string ToString() { } + } public sealed class LoadSnapshot : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IPersistenceMessage, Akka.Persistence.ISnapshotMessage, Akka.Persistence.ISnapshotRequest, System.IEquatable { public LoadSnapshot(string persistenceId, Akka.Persistence.SnapshotSelectionCriteria criteria, long toSequenceNr) { } @@ -377,12 +397,36 @@ namespace Akka.Persistence public Akka.Persistence.IStashOverflowStrategy DefaultInternalStashOverflowStrategy { get; } public Akka.Persistence.PersistenceSettings Settings { get; } public Akka.Persistence.Journal.EventAdapters AdaptersFor(string journalPluginId) { } + public System.Threading.Tasks.Task CheckJournalHealthAsync(string journalPluginId, System.Threading.CancellationToken cancellationToken = null) { } + public System.Threading.Tasks.Task CheckSnapshotStoreHealthAsync(string snapshotStorePluginId, System.Threading.CancellationToken cancellationToken = null) { } [Akka.Annotations.InternalStableApiAttribute()] public Akka.Actor.IActorRef JournalFor(string journalPluginId) { } public string PersistenceId(Akka.Actor.IActorRef actor) { } [Akka.Annotations.InternalStableApiAttribute()] public Akka.Actor.IActorRef SnapshotStoreFor(string snapshotPluginId) { } } + [System.Runtime.CompilerServices.NullableAttribute(0)] + public struct PersistenceHealthCheckResult : System.IEquatable + { + public PersistenceHealthCheckResult(Akka.Persistence.PersistenceHealthStatus Status, string Description = null, System.Exception Exception = null, [System.Runtime.CompilerServices.NullableAttribute(new byte[] { + 2, + 0, + 0})] System.Collections.Generic.IReadOnlyDictionary Data = null) { } + [System.Runtime.CompilerServices.NullableAttribute(new byte[] { + 2, + 0, + 0})] + public System.Collections.Generic.IReadOnlyDictionary Data { get; set; } + public string Description { get; set; } + public System.Exception Exception { get; set; } + public Akka.Persistence.PersistenceHealthStatus Status { get; set; } + } + public enum PersistenceHealthStatus + { + Healthy = 0, + Degraded = 1, + Unhealthy = 2, + } public sealed class PersistenceSettings : Akka.Actor.Settings { public PersistenceSettings(Akka.Actor.ActorSystem system, Akka.Configuration.Config config) { } @@ -627,11 +671,11 @@ namespace Akka.Persistence public override int GetHashCode() { } public override string ToString() { } } - public sealed class StashingHandlerInvocation : Akka.Persistence.IPendingHandlerInvocation + public sealed class SnapshotStoreHealthCheckResponse : Akka.Actor.INoSerializationVerificationNeeded, Akka.Persistence.IPersistenceMessage, Akka.Persistence.ISnapshotMessage, Akka.Persistence.ISnapshotResponse { - public StashingHandlerInvocation(object evt, System.Action handler) { } - public object Event { get; } - public System.Action Handler { get; } + public SnapshotStoreHealthCheckResponse(Akka.Persistence.PersistenceHealthCheckResult result) { } + public Akka.Persistence.PersistenceHealthCheckResult Result { get; } + public override string ToString() { } } public sealed class ThrowExceptionConfigurator : Akka.Persistence.IStashOverflowStrategyConfigurator { @@ -857,6 +901,7 @@ namespace Akka.Persistence.Journal { protected readonly bool CanPublish; protected AsyncWriteJournal() { } + public virtual System.Threading.Tasks.Task CheckHealthAsync(System.Threading.CancellationToken cancellationToken = null) { } protected abstract System.Threading.Tasks.Task DeleteMessagesToAsync(string persistenceId, long toSequenceNr, System.Threading.CancellationToken cancellationToken); public abstract System.Threading.Tasks.Task ReadHighestSequenceNrAsync(string persistenceId, long fromSequenceNr, System.Threading.CancellationToken cancellationToken); protected virtual bool Receive(object message) { } @@ -1213,6 +1258,7 @@ namespace Akka.Persistence.Snapshot public abstract class SnapshotStore : Akka.Actor.ActorBase { protected SnapshotStore() { } + public virtual System.Threading.Tasks.Task CheckHealthAsync(System.Threading.CancellationToken cancellationToken = null) { } protected abstract System.Threading.Tasks.Task DeleteAsync(Akka.Persistence.SnapshotMetadata metadata, System.Threading.CancellationToken cancellationToken); protected abstract System.Threading.Tasks.Task DeleteAsync(string persistenceId, Akka.Persistence.SnapshotSelectionCriteria criteria, System.Threading.CancellationToken cancellationToken); protected abstract System.Threading.Tasks.Task LoadAsync(string persistenceId, Akka.Persistence.SnapshotSelectionCriteria criteria, System.Threading.CancellationToken cancellationToken); diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.DotNet.verified.txt index bcc6ce10a27..aae52811ad8 100644 --- a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.DotNet.verified.txt +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.DotNet.verified.txt @@ -860,10 +860,36 @@ namespace Akka.Remote.Transport } namespace Akka.Remote.Transport.DotNetty { + [System.Runtime.CompilerServices.NullableAttribute(0)] + public class static CertificateValidation + { + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ChainPlusThen([System.Runtime.CompilerServices.NullableAttribute(new byte[] { + 1, + 2, + 2, + 1})] System.Func customCheck, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback Combine(params Akka.Remote.Transport.DotNetty.CertificateValidationCallback[] validators) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback PinnedCertificate(params string[] allowedThumbprints) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateChain([System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + [return: System.Runtime.CompilerServices.NullableAttribute(1)] + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateHostname(string expectedHostname = null, Akka.Event.ILoggingAdapter log = null) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateIssuer(string expectedIssuerPattern, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateSubject(string expectedSubjectPattern, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + } + public delegate bool CertificateValidationCallback([System.Runtime.CompilerServices.NullableAttribute(2)] System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, [System.Runtime.CompilerServices.NullableAttribute(2)] System.Security.Cryptography.X509Certificates.X509Chain chain, string remotePeer, System.Net.Security.SslPolicyErrors errors, Akka.Event.ILoggingAdapter log); + [System.Runtime.CompilerServices.NullableAttribute(0)] public sealed class DotNettySslSetup : Akka.Actor.Setup.Setup { public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Remote.Transport.DotNetty.CertificateValidationCallback customValidator) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Remote.Transport.DotNetty.CertificateValidationCallback customValidator) { } public System.Security.Cryptography.X509Certificates.X509Certificate2 Certificate { get; } + [System.Runtime.CompilerServices.NullableAttribute(2)] + public Akka.Remote.Transport.DotNetty.CertificateValidationCallback CustomValidator { get; } + public bool RequireMutualAuthentication { get; } public bool SuppressValidation { get; } + public bool ValidateCertificateHostname { get; } } } \ No newline at end of file diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.Net.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.Net.verified.txt index 5ae71f05f74..cb1f4ab80ca 100644 --- a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.Net.verified.txt +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.Net.verified.txt @@ -860,10 +860,36 @@ namespace Akka.Remote.Transport } namespace Akka.Remote.Transport.DotNetty { + [System.Runtime.CompilerServices.NullableAttribute(0)] + public class static CertificateValidation + { + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ChainPlusThen([System.Runtime.CompilerServices.NullableAttribute(new byte[] { + 1, + 2, + 2, + 1})] System.Func customCheck, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback Combine(params Akka.Remote.Transport.DotNetty.CertificateValidationCallback[] validators) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback PinnedCertificate(params string[] allowedThumbprints) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateChain([System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + [return: System.Runtime.CompilerServices.NullableAttribute(1)] + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateHostname(string expectedHostname = null, Akka.Event.ILoggingAdapter log = null) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateIssuer(string expectedIssuerPattern, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateSubject(string expectedSubjectPattern, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + } + public delegate bool CertificateValidationCallback([System.Runtime.CompilerServices.NullableAttribute(2)] System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, [System.Runtime.CompilerServices.NullableAttribute(2)] System.Security.Cryptography.X509Certificates.X509Chain chain, string remotePeer, System.Net.Security.SslPolicyErrors errors, Akka.Event.ILoggingAdapter log); + [System.Runtime.CompilerServices.NullableAttribute(0)] public sealed class DotNettySslSetup : Akka.Actor.Setup.Setup { public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Remote.Transport.DotNetty.CertificateValidationCallback customValidator) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Remote.Transport.DotNetty.CertificateValidationCallback customValidator) { } public System.Security.Cryptography.X509Certificates.X509Certificate2 Certificate { get; } + [System.Runtime.CompilerServices.NullableAttribute(2)] + public Akka.Remote.Transport.DotNetty.CertificateValidationCallback CustomValidator { get; } + public bool RequireMutualAuthentication { get; } public bool SuppressValidation { get; } + public bool ValidateCertificateHostname { get; } } } \ No newline at end of file diff --git a/src/core/Akka.API.Tests/verify/DefaultLogFormatSpec.ShouldHandleSemanticLogEdgeCases.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/DefaultLogFormatSpec.ShouldHandleSemanticLogEdgeCases.DotNet.verified.txt new file mode 100644 index 00000000000..e2c9fd90769 --- /dev/null +++ b/src/core/Akka.API.Tests/verify/DefaultLogFormatSpec.ShouldHandleSemanticLogEdgeCases.DotNet.verified.txt @@ -0,0 +1,11 @@ +[DEBUG][DateTime][Thread 0001][ActorSystem(test)] User 12345 logged in from 192.168.1.1 +[INFO][DateTime][Thread 0001][ActorSystem(test)] Processing order ORD-001 for customer CUST-999 +[WARNING][DateTime][Thread 0001][ActorSystem(test)] Processing item 5 of 10 +[INFO][DateTime][Thread 0001][ActorSystem(test)] Order total is $123.45 with 3 items +[DEBUG][DateTime][Thread 0001][ActorSystem(test)] Empty template +[INFO][DateTime][Thread 0001][ActorSystem(test)] Single property 42 +[WARNING][DateTime][Thread 0001][ActorSystem(test)] Null value: {NullValue} +[ERROR][DateTime][Thread 0001][ActorSystem(test)] Exception occurred for user 999 +[DEBUG][DateTime][Thread 0001][ActorSystem(test)] Path: C:\temp\file.txt, Size: 1024 bytes +[INFO][DateTime][Thread 0001][ActorSystem(test)] User john.doe is active: True, joined on 2024-01-15 +[DEBUG][DateTime][Thread 0001][ActorSystem(test)] Request from 192.168.1.100:54321 to endpoint /api/v1/users took 250ms diff --git a/src/core/Akka.API.Tests/verify/DefaultLogFormatSpec.ShouldHandleSemanticLogEdgeCases.Net.verified.txt b/src/core/Akka.API.Tests/verify/DefaultLogFormatSpec.ShouldHandleSemanticLogEdgeCases.Net.verified.txt new file mode 100644 index 00000000000..e2c9fd90769 --- /dev/null +++ b/src/core/Akka.API.Tests/verify/DefaultLogFormatSpec.ShouldHandleSemanticLogEdgeCases.Net.verified.txt @@ -0,0 +1,11 @@ +[DEBUG][DateTime][Thread 0001][ActorSystem(test)] User 12345 logged in from 192.168.1.1 +[INFO][DateTime][Thread 0001][ActorSystem(test)] Processing order ORD-001 for customer CUST-999 +[WARNING][DateTime][Thread 0001][ActorSystem(test)] Processing item 5 of 10 +[INFO][DateTime][Thread 0001][ActorSystem(test)] Order total is $123.45 with 3 items +[DEBUG][DateTime][Thread 0001][ActorSystem(test)] Empty template +[INFO][DateTime][Thread 0001][ActorSystem(test)] Single property 42 +[WARNING][DateTime][Thread 0001][ActorSystem(test)] Null value: {NullValue} +[ERROR][DateTime][Thread 0001][ActorSystem(test)] Exception occurred for user 999 +[DEBUG][DateTime][Thread 0001][ActorSystem(test)] Path: C:\temp\file.txt, Size: 1024 bytes +[INFO][DateTime][Thread 0001][ActorSystem(test)] User john.doe is active: True, joined on 2024-01-15 +[DEBUG][DateTime][Thread 0001][ActorSystem(test)] Request from 192.168.1.100:54321 to endpoint /api/v1/users took 250ms diff --git a/src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs b/src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs new file mode 100644 index 00000000000..551644e9c07 --- /dev/null +++ b/src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs @@ -0,0 +1,215 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System.Security.Cryptography.X509Certificates; +using Akka.Actor.Setup; +using Akka.Configuration; +using Akka.Remote.Transport.DotNetty; + +namespace Akka.Docs.Tests.Configuration +{ + /// + /// TLS configuration examples for Akka.Remote documentation + /// + public class TlsConfigurationSample + { + #region MutualTlsConfig + public static Config MutualTlsConfiguration = ConfigurationFactory.ParseString(@" + akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false + require-mutual-authentication = true # Both client and server authenticate + certificate { + path = ""path/to/certificate.pfx"" + password = ""certificate-password"" + } + } + } + "); + #endregion + + #region StandardTlsConfig + public static Config StandardTlsConfiguration = ConfigurationFactory.ParseString(@" + akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false + require-mutual-authentication = false # Server authentication only + certificate { + path = ""path/to/certificate.pfx"" + password = ""certificate-password"" + } + } + } + "); + #endregion + + #region WindowsCertStoreConfig + public static Config WindowsCertificateStoreConfiguration = ConfigurationFactory.ParseString(@" + akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false + require-mutual-authentication = true + certificate { + use-thumbprint-over-file = true + thumbprint = ""2531c78c51e5041d02564697a88af8bc7a7ce3e3"" + store-name = ""My"" + store-location = ""local-machine"" # or ""current-user"" + } + } + } + "); + #endregion + + #region DevTlsConfig + // WARNING: Development only - never use suppress-validation = true in production! + public static Config DevelopmentTlsConfiguration = ConfigurationFactory.ParseString(@" + akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = true # INSECURE: Accepts any certificate + require-mutual-authentication = false + certificate { + path = ""self-signed-dev-cert.pfx"" + password = ""password"" + } + } + } + "); + #endregion + + #region ProgrammaticMutualTlsSetup + /// + /// Example of programmatic mutual TLS setup using DotNettySslSetup with custom validation. + /// This allows full programmatic control over certificate validation logic. + /// + public static void ProgrammaticMutualTlsSetup() + { + // Load or obtain your certificate + var certificate = new X509Certificate2("path/to/certificate.pfx", "password"); + + // Create custom validator combining multiple validation strategies + var customValidator = CertificateValidation.Combine( + // Validate the certificate chain + CertificateValidation.ValidateChain(), + // Also pin against known thumbprints for additional security + CertificateValidation.PinnedCertificate(certificate.Thumbprint) + ); + + // Setup SSL with custom validator taking precedence over HOCON config + var sslSetup = new DotNettySslSetup( + certificate: certificate, + suppressValidation: false, + requireMutualAuthentication: true, + customValidator: customValidator + ); + } + #endregion + + #region CertificatePinningExample + /// + /// Example of certificate pinning - only accept certificates with specific thumbprints. + /// Useful for preventing man-in-the-middle attacks with compromised CAs. + /// + public static void CertificatePinningSetup() + { + var certificate = new X509Certificate2("path/to/certificate.pfx", "password"); + + // Allow only specific certificates by thumbprint + var validator = CertificateValidation.PinnedCertificate( + "2531c78c51e5041d02564697a88af8bc7a7ce3e3", // Production cert + "abc123def456789ghi012jkl345mno678pqr901stu" // Backup cert + ); + + var sslSetup = new DotNettySslSetup( + certificate: certificate, + suppressValidation: false, + requireMutualAuthentication: true, + customValidator: validator + ); + } + #endregion + + #region CustomValidationLogicExample + /// + /// Example of custom certificate validation logic combined with standard validation. + /// Allows complete control over what certificates are accepted. + /// + public static void CustomValidationLogicSetup() + { + var certificate = new X509Certificate2("path/to/certificate.pfx", "password"); + + // Start with standard chain validation, then add custom logic + var validator = CertificateValidation.ChainPlusThen( + // Custom validation - check certificate subject matches expected peer + (cert, chain, peer) => + { + // Accept only certificates from authorized-peer + if (cert?.Subject != null && cert.Subject.Contains("CN=authorized-peer")) + { + return true; // Accept this certificate + } + return false; // Reject all others + } + ); + + var sslSetup = new DotNettySslSetup( + certificate: certificate, + suppressValidation: false, + requireMutualAuthentication: true, + customValidator: validator + ); + } + #endregion + + #region HostnameValidationExample + /// + /// Example of enabling traditional hostname validation for client-server architectures. + /// Use when all nodes share the same certificate with matching CN/SAN. + /// + public static void HostnameValidationSetup() + { + var certificate = new X509Certificate2("path/to/certificate.pfx", "password"); + + // Enable both chain validation and hostname validation + var sslSetup = new DotNettySslSetup( + certificate: certificate, + suppressValidation: false, + requireMutualAuthentication: true, + validateCertificateHostname: true // Enable traditional TLS hostname validation + ); + } + #endregion + + #region SubjectValidationExample + /// + /// Example of subject DN validation - only accept certificates with specific subject names. + /// Useful for verifying peer identity based on certificate subject. + /// Supports wildcards: "CN=Akka-Node-*" matches "CN=Akka-Node-001" + /// + public static void SubjectValidationSetup() + { + var certificate = new X509Certificate2("path/to/certificate.pfx", "password"); + + // Accept certificates matching the subject pattern + // Wildcards are supported: CN=Akka-Node-* matches CN=Akka-Node-001 + var validator = CertificateValidation.ValidateSubject( + "CN=Akka-Node-*" // Pattern to match + ); + + var sslSetup = new DotNettySslSetup( + certificate: certificate, + suppressValidation: false, + requireMutualAuthentication: true, + customValidator: validator + ); + } + #endregion + } +} \ No newline at end of file diff --git a/src/core/Akka.Persistence.Tests/JournalHealthCheckSpec.cs b/src/core/Akka.Persistence.Tests/JournalHealthCheckSpec.cs new file mode 100644 index 00000000000..eb025970658 --- /dev/null +++ b/src/core/Akka.Persistence.Tests/JournalHealthCheckSpec.cs @@ -0,0 +1,132 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Akka.Configuration; +using Akka.Persistence.Journal; +using Akka.TestKit; +using Akka.TestKit.Configs; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Persistence.Tests; + +public class JournalHealthCheckSpec : PersistenceSpec +{ + private static Config HealthCheckConfig() + { + const string extraConfig = """ + + akka.persistence.journal.failing-open { + class = "Akka.Persistence.Tests.FailingJournal, Akka.Persistence.Tests" + circuit-breaker { + max-failures = 1 + call-timeout = 1s + reset-timeout = 10s + } + } + akka.persistence.journal.failing-half-open { + class = "Akka.Persistence.Tests.FailingJournal, Akka.Persistence.Tests" + circuit-breaker { + max-failures = 1 + call-timeout = 1s + reset-timeout = 1s + } + } + # Disable message serialization for circuit breaker tests to avoid serialization issues + akka.actor.serialize-messages = off + + """; + return TestConfigs.TestSchedulerConfig + .WithFallback(Configuration("PersistenceHealthCheckSpec", extraConfig: extraConfig)); + } + + public JournalHealthCheckSpec(ITestOutputHelper output) : base(HealthCheckConfig(), output) + { + } + + [Theory] + [InlineData(null)] // default plugin + [InlineData("akka.persistence.journal.inmem")] + public async Task JournalHealthCheck_should_default_to_Healthy(string? pluginId) + { + using var cts = new CancellationTokenSource(RemainingOrDefault); + var pluginHealth = await Extension.CheckJournalHealthAsync(pluginId, cts.Token); + + Assert.Equal(PersistenceHealthStatus.Healthy, pluginHealth.Status); + Assert.NotNull(pluginHealth.Description); + } + + [Fact] + public async Task JournalHealthCheck_should_return_Degraded_when_CircuitBreaker_is_Open() + { + // Get the journal actor reference + var journal = Extension.JournalFor("akka.persistence.journal.failing-open"); + + // Trigger a failure to open the circuit breaker + var writeMsg = new WriteMessages(new[] { new AtomicWrite(new Persistent("test", 1, "test-pid")) }.ToImmutableList(), + TestActor, 1); + journal.Tell(writeMsg, TestActor); + + // Advance time to let the write fail and circuit breaker open + var testScheduler = (TestScheduler)Sys.Scheduler; + testScheduler.Advance(TimeSpan.FromSeconds(2)); + + using var cts = new CancellationTokenSource(RemainingOrDefault); + var pluginHealth = await Extension.CheckJournalHealthAsync("akka.persistence.journal.failing-open", cts.Token); + + Assert.Equal(PersistenceHealthStatus.Degraded, pluginHealth.Status); + Assert.Contains("Circuit breaker is open", pluginHealth.Description); + } + + [Fact] + public async Task JournalHealthCheck_should_return_Degraded_when_CircuitBreaker_is_HalfOpen() + { + // Get the journal actor reference + var journal = Extension.JournalFor("akka.persistence.journal.failing-half-open"); + + // Trigger a failure to open the circuit breaker + var writeMsg = new WriteMessages(new[] { new AtomicWrite(new Persistent("test", 1, "test-pid")) }.ToImmutableList(), + TestActor, 1); + journal.Tell(writeMsg, TestActor); + + var testScheduler = (TestScheduler)Sys.Scheduler; + + // Advance time past call-timeout to let the write fail and circuit breaker open + testScheduler.Advance(TimeSpan.FromSeconds(1)); + + // Give the async operations time to complete + await Task.Delay(100); + + // Advance time past reset-timeout to transition to half-open + testScheduler.Advance(TimeSpan.FromSeconds(1)); + + // Give the transition time to complete + await Task.Delay(100); + + using var cts = new CancellationTokenSource(RemainingOrDefault); + var pluginHealth = await Extension.CheckJournalHealthAsync("akka.persistence.journal.failing-half-open", cts.Token); + + Assert.Equal(PersistenceHealthStatus.Degraded, pluginHealth.Status); + Assert.Contains("Circuit breaker is half-open", pluginHealth.Description); + } +} + +/// +/// Test journal that always fails writes to trigger circuit breaker +/// +public class FailingJournal : MemoryJournal +{ + protected override Task> WriteMessagesAsync(IEnumerable messages, CancellationToken cancellationToken) + { + throw new InvalidOperationException("Simulated journal write failure"); + } +} \ No newline at end of file diff --git a/src/core/Akka.Persistence.Tests/PersistenceCompletionCallbackSpec.cs b/src/core/Akka.Persistence.Tests/PersistenceCompletionCallbackSpec.cs new file mode 100644 index 00000000000..c80787023ff --- /dev/null +++ b/src/core/Akka.Persistence.Tests/PersistenceCompletionCallbackSpec.cs @@ -0,0 +1,886 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.TestKit; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Persistence.Tests +{ + /// + /// Tests for persistence completion callbacks and async handler support. + /// + public class PersistenceCompletionCallbackSpec : PersistenceSpec + { + public PersistenceCompletionCallbackSpec(ITestOutputHelper output) + : base(Configuration("PersistenceCompletionCallbackSpec"), output) + { + } + + #region Test Actors + + private class TestEvent + { + public string Data { get; } + public TestEvent(string data) => Data = data; + } + + private class GetEvents + { + public static readonly GetEvents Instance = new(); + private GetEvents() { } + } + + private class GetCompletionOrder + { + public static readonly GetCompletionOrder Instance = new(); + private GetCompletionOrder() { } + } + + /// + /// Actor that tests PersistAll with sync completion callback + /// + private class PersistAllWithCompletionActor : UntypedPersistentActor + { + private readonly List _events = new(); + private readonly List _completionOrder = new(); + private readonly IActorRef _probe; + + public override string PersistenceId { get; } + + public PersistAllWithCompletionActor(string persistenceId, IActorRef probe) + { + PersistenceId = persistenceId; + _probe = probe; + } + + protected override void OnRecover(object message) + { + if (message is TestEvent evt) + _events.Add(evt.Data); + } + + protected override void OnCommand(object message) + { + switch (message) + { + case string[] events: + var testEvents = new List(); + foreach (var e in events) + testEvents.Add(new TestEvent(e)); + + PersistAll(testEvents, evt => + { + _events.Add(evt.Data); + _completionOrder.Add($"handler:{evt.Data}"); + }, () => + { + _completionOrder.Add("completion"); + _probe.Tell("completed"); + }); + break; + + case GetEvents: + Sender.Tell(_events.ToArray()); + break; + + case GetCompletionOrder: + Sender.Tell(_completionOrder.ToArray()); + break; + } + } + } + + /// + /// Actor that tests PersistAll with async completion callback + /// + private class PersistAllWithAsyncCompletionActor : UntypedPersistentActor + { + private readonly List _events = new(); + private readonly List _completionOrder = new(); + private readonly IActorRef _probe; + + public override string PersistenceId { get; } + + public PersistAllWithAsyncCompletionActor(string persistenceId, IActorRef probe) + { + PersistenceId = persistenceId; + _probe = probe; + } + + protected override void OnRecover(object message) + { + if (message is TestEvent evt) + _events.Add(evt.Data); + } + + protected override void OnCommand(object message) + { + switch (message) + { + case string[] events: + var testEvents = new List(); + foreach (var e in events) + testEvents.Add(new TestEvent(e)); + + PersistAll(testEvents, evt => + { + _events.Add(evt.Data); + _completionOrder.Add($"handler:{evt.Data}"); + }, async () => + { + await Task.Delay(10); + _completionOrder.Add("async-completion"); + _probe.Tell("completed"); + }); + break; + + case GetEvents: + Sender.Tell(_events.ToArray()); + break; + + case GetCompletionOrder: + Sender.Tell(_completionOrder.ToArray()); + break; + } + } + } + + /// + /// Actor that tests PersistAllAsync with sync completion callback + /// + private class PersistAllAsyncWithCompletionActor : UntypedPersistentActor + { + private readonly List _events = new(); + private readonly List _completionOrder = new(); + private readonly IActorRef _probe; + + public override string PersistenceId { get; } + + public PersistAllAsyncWithCompletionActor(string persistenceId, IActorRef probe) + { + PersistenceId = persistenceId; + _probe = probe; + } + + protected override void OnRecover(object message) + { + if (message is TestEvent evt) + _events.Add(evt.Data); + } + + protected override void OnCommand(object message) + { + switch (message) + { + case string[] events: + var testEvents = new List(); + foreach (var e in events) + testEvents.Add(new TestEvent(e)); + + PersistAllAsync(testEvents, evt => + { + _events.Add(evt.Data); + _completionOrder.Add($"handler:{evt.Data}"); + }, () => + { + _completionOrder.Add("completion"); + _probe.Tell("completed"); + }); + break; + + case GetEvents: + Sender.Tell(_events.ToArray()); + break; + + case GetCompletionOrder: + Sender.Tell(_completionOrder.ToArray()); + break; + } + } + } + + /// + /// Actor that tests Persist with async handler + /// + private class PersistWithAsyncHandlerActor : UntypedPersistentActor + { + private readonly List _events = new(); + private readonly List _completionOrder = new(); + private readonly IActorRef _probe; + + public override string PersistenceId { get; } + + public PersistWithAsyncHandlerActor(string persistenceId, IActorRef probe) + { + PersistenceId = persistenceId; + _probe = probe; + } + + protected override void OnRecover(object message) + { + if (message is TestEvent evt) + _events.Add(evt.Data); + } + + protected override void OnCommand(object message) + { + switch (message) + { + case string eventData: + Persist(new TestEvent(eventData), async evt => + { + await Task.Delay(10); + _events.Add(evt.Data); + _completionOrder.Add($"async-handler:{evt.Data}"); + _probe.Tell("handled"); + }); + break; + + case GetEvents: + Sender.Tell(_events.ToArray()); + break; + + case GetCompletionOrder: + Sender.Tell(_completionOrder.ToArray()); + break; + } + } + } + + /// + /// Actor that tests PersistAsync with async handler + /// + private class PersistAsyncWithAsyncHandlerActor : UntypedPersistentActor + { + private readonly List _events = new(); + private readonly List _completionOrder = new(); + private readonly IActorRef _probe; + + public override string PersistenceId { get; } + + public PersistAsyncWithAsyncHandlerActor(string persistenceId, IActorRef probe) + { + PersistenceId = persistenceId; + _probe = probe; + } + + protected override void OnRecover(object message) + { + if (message is TestEvent evt) + _events.Add(evt.Data); + } + + protected override void OnCommand(object message) + { + switch (message) + { + case string eventData: + PersistAsync(new TestEvent(eventData), async evt => + { + await Task.Delay(10); + _events.Add(evt.Data); + _completionOrder.Add($"async-handler:{evt.Data}"); + _probe.Tell("handled"); + }); + break; + + case GetEvents: + Sender.Tell(_events.ToArray()); + break; + + case GetCompletionOrder: + Sender.Tell(_completionOrder.ToArray()); + break; + } + } + } + + /// + /// Actor that tests DeferAsync with async handler + /// + private class DeferAsyncWithAsyncHandlerActor : UntypedPersistentActor + { + private readonly List _events = new(); + private readonly List _completionOrder = new(); + private readonly IActorRef _probe; + + public override string PersistenceId { get; } + + public DeferAsyncWithAsyncHandlerActor(string persistenceId, IActorRef probe) + { + PersistenceId = persistenceId; + _probe = probe; + } + + protected override void OnRecover(object message) + { + if (message is TestEvent evt) + _events.Add(evt.Data); + } + + protected override void OnCommand(object message) + { + switch (message) + { + case string[] events: + // First persist events, then defer async + var testEvents = new List(); + foreach (var e in events) + testEvents.Add(new TestEvent(e)); + + PersistAllAsync(testEvents, evt => + { + _events.Add(evt.Data); + _completionOrder.Add($"handler:{evt.Data}"); + }); + + DeferAsync("deferred", async _ => + { + await Task.Delay(10); + _completionOrder.Add("async-deferred"); + _probe.Tell("deferred"); + }); + break; + + case GetEvents: + Sender.Tell(_events.ToArray()); + break; + + case GetCompletionOrder: + Sender.Tell(_completionOrder.ToArray()); + break; + } + } + } + + /// + /// Actor that tests PersistAll with async handlers + /// + private class PersistAllWithAsyncHandlerActor : UntypedPersistentActor + { + private readonly List _events = new(); + private readonly List _completionOrder = new(); + private readonly IActorRef _probe; + + public override string PersistenceId { get; } + + public PersistAllWithAsyncHandlerActor(string persistenceId, IActorRef probe) + { + PersistenceId = persistenceId; + _probe = probe; + } + + protected override void OnRecover(object message) + { + if (message is TestEvent evt) + _events.Add(evt.Data); + } + + protected override void OnCommand(object message) + { + switch (message) + { + case string[] events: + var testEvents = new List(); + foreach (var e in events) + testEvents.Add(new TestEvent(e)); + + PersistAll(testEvents, async evt => + { + await Task.Delay(10); + _events.Add(evt.Data); + _completionOrder.Add($"async-handler:{evt.Data}"); + }, () => + { + _completionOrder.Add("completion"); + _probe.Tell("completed"); + }); + break; + + case GetEvents: + Sender.Tell(_events.ToArray()); + break; + + case GetCompletionOrder: + Sender.Tell(_completionOrder.ToArray()); + break; + } + } + } + + /// + /// Actor that tests stashing behavior - commands should be stashed during PersistAll + /// + private class StashingBehaviorTestActor : UntypedPersistentActor + { + private readonly List _commandOrder = new(); + private readonly IActorRef _probe; + + public override string PersistenceId { get; } + + public StashingBehaviorTestActor(string persistenceId, IActorRef probe) + { + PersistenceId = persistenceId; + _probe = probe; + } + + protected override void OnRecover(object message) { } + + protected override void OnCommand(object message) + { + switch (message) + { + case "persist": + _commandOrder.Add("persist-start"); + PersistAll(new[] { new TestEvent("a"), new TestEvent("b") }, evt => + { + _commandOrder.Add($"handler:{evt.Data}"); + }, () => + { + _commandOrder.Add("completion"); + }); + _commandOrder.Add("persist-end"); + break; + + case "other": + _commandOrder.Add("other-command"); + _probe.Tell("other-processed"); + break; + + case "get-order": + Sender.Tell(_commandOrder.ToArray()); + break; + } + } + } + + /// + /// Actor that tests sequential persist operations to verify ordering is maintained + /// even when empty events are involved + /// + private class SequentialPersistOrderingActor : UntypedPersistentActor + { + private readonly List _executionOrder = new(); + private readonly IActorRef _probe; + + public override string PersistenceId { get; } + + public SequentialPersistOrderingActor(string persistenceId, IActorRef probe) + { + PersistenceId = persistenceId; + _probe = probe; + } + + protected override void OnRecover(object message) { } + + protected override void OnCommand(object message) + { + switch (message) + { + // Test: Persist followed by PersistAll with empty events + // The empty PersistAll completion should run AFTER the Persist handler + case "persist-then-empty": + Persist(new TestEvent("first"), evt => + { + _executionOrder.Add($"persist-handler:{evt.Data}"); + }); + PersistAll(Array.Empty(), _ => { }, () => + { + _executionOrder.Add("empty-completion"); + _probe.Tell("done"); + }); + break; + + // Test: Multiple PersistAll calls where middle one is empty + case "persist-empty-persist": + PersistAll(new[] { new TestEvent("first") }, evt => + { + _executionOrder.Add($"first-handler:{evt.Data}"); + }, () => + { + _executionOrder.Add("first-completion"); + }); + PersistAll(Array.Empty(), _ => { }, () => + { + _executionOrder.Add("empty-completion"); + }); + PersistAll(new[] { new TestEvent("last") }, evt => + { + _executionOrder.Add($"last-handler:{evt.Data}"); + }, () => + { + _executionOrder.Add("last-completion"); + _probe.Tell("done"); + }); + break; + + case "get-order": + Sender.Tell(_executionOrder.ToArray()); + break; + } + } + } + + /// + /// Actor that tests empty event list with various completion callback overloads + /// + private class EmptyEventsWithCompletionActor : UntypedPersistentActor + { + private readonly IActorRef _probe; + private bool _completionCalled; + + public override string PersistenceId { get; } + + public EmptyEventsWithCompletionActor(string persistenceId, IActorRef probe) + { + PersistenceId = persistenceId; + _probe = probe; + } + + protected override void OnRecover(object message) { } + + protected override void OnCommand(object message) + { + switch (message) + { + // PersistAll with sync completion callback + case "persist-empty-sync": + PersistAll(Array.Empty(), _ => { }, () => + { + _completionCalled = true; + _probe.Tell("completed"); + }); + break; + + // PersistAll with async completion callback + case "persist-empty-async": + PersistAll(Array.Empty(), _ => { }, async () => + { + await Task.Yield(); + _completionCalled = true; + _probe.Tell("completed"); + }); + break; + + // PersistAllAsync with sync completion callback + case "persist-async-empty-sync": + PersistAllAsync(Array.Empty(), _ => { }, () => + { + _completionCalled = true; + _probe.Tell("completed"); + }); + break; + + // PersistAllAsync with async completion callback + case "persist-async-empty-async": + PersistAllAsync(Array.Empty(), _ => { }, async () => + { + await Task.Yield(); + _completionCalled = true; + _probe.Tell("completed"); + }); + break; + + case "check": + Sender.Tell(_completionCalled); + break; + + case "reset": + _completionCalled = false; + Sender.Tell("reset-done"); + break; + } + } + } + + #endregion + + #region Tests + + [Fact(DisplayName = "PersistAll with sync completion callback should invoke callback after all handlers")] + public async Task PersistAll_WithSyncCompletion_Should_InvokeAfterAllHandlers() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new PersistAllWithCompletionActor(Name, probe))); + + actor.Tell(new[] { "event1", "event2", "event3" }); + await probe.ExpectMsgAsync("completed"); + + actor.Tell(GetCompletionOrder.Instance); + var order = await ExpectMsgAsync(); + + order.Should().BeEquivalentTo(new[] + { + "handler:event1", + "handler:event2", + "handler:event3", + "completion" + }, options => options.WithStrictOrdering()); + } + + [Fact(DisplayName = "PersistAll with async completion callback should invoke callback after all handlers")] + public async Task PersistAll_WithAsyncCompletion_Should_InvokeAfterAllHandlers() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new PersistAllWithAsyncCompletionActor(Name, probe))); + + actor.Tell(new[] { "event1", "event2", "event3" }); + await probe.ExpectMsgAsync("completed"); + + actor.Tell(GetCompletionOrder.Instance); + var order = await ExpectMsgAsync(); + + order.Should().BeEquivalentTo(new[] + { + "handler:event1", + "handler:event2", + "handler:event3", + "async-completion" + }, options => options.WithStrictOrdering()); + } + + [Fact(DisplayName = "PersistAllAsync with sync completion callback should invoke callback after all handlers")] + public async Task PersistAllAsync_WithSyncCompletion_Should_InvokeAfterAllHandlers() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new PersistAllAsyncWithCompletionActor(Name, probe))); + + actor.Tell(new[] { "event1", "event2", "event3" }); + await probe.ExpectMsgAsync("completed"); + + actor.Tell(GetCompletionOrder.Instance); + var order = await ExpectMsgAsync(); + + order.Should().BeEquivalentTo(new[] + { + "handler:event1", + "handler:event2", + "handler:event3", + "completion" + }, options => options.WithStrictOrdering()); + } + + [Fact(DisplayName = "Persist with async handler should execute handler asynchronously")] + public async Task Persist_WithAsyncHandler_Should_ExecuteAsynchronously() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new PersistWithAsyncHandlerActor(Name, probe))); + + actor.Tell("event1"); + await probe.ExpectMsgAsync("handled"); + + actor.Tell(GetCompletionOrder.Instance); + var order = await ExpectMsgAsync(); + + order.Should().Contain("async-handler:event1"); + } + + [Fact(DisplayName = "PersistAsync with async handler should execute handler asynchronously")] + public async Task PersistAsync_WithAsyncHandler_Should_ExecuteAsynchronously() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new PersistAsyncWithAsyncHandlerActor(Name, probe))); + + actor.Tell("event1"); + await probe.ExpectMsgAsync("handled"); + + actor.Tell(GetCompletionOrder.Instance); + var order = await ExpectMsgAsync(); + + order.Should().Contain("async-handler:event1"); + } + + [Fact(DisplayName = "DeferAsync with async handler should execute after pending invocations")] + public async Task DeferAsync_WithAsyncHandler_Should_ExecuteAfterPending() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new DeferAsyncWithAsyncHandlerActor(Name, probe))); + + actor.Tell(new[] { "event1", "event2" }); + await probe.ExpectMsgAsync("deferred"); + + actor.Tell(GetCompletionOrder.Instance); + var order = await ExpectMsgAsync(); + + order.Should().BeEquivalentTo(new[] + { + "handler:event1", + "handler:event2", + "async-deferred" + }, options => options.WithStrictOrdering()); + } + + [Fact(DisplayName = "PersistAll with async handlers should execute handlers and completion in order")] + public async Task PersistAll_WithAsyncHandlers_Should_ExecuteInOrder() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new PersistAllWithAsyncHandlerActor(Name, probe))); + + actor.Tell(new[] { "event1", "event2" }); + await probe.ExpectMsgAsync("completed"); + + actor.Tell(GetCompletionOrder.Instance); + var order = await ExpectMsgAsync(); + + order.Should().BeEquivalentTo(new[] + { + "async-handler:event1", + "async-handler:event2", + "completion" + }, options => options.WithStrictOrdering()); + } + + [Fact(DisplayName = "PersistAll should stash commands until completion callback finishes")] + public async Task PersistAll_Should_StashCommandsUntilCompletion() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new StashingBehaviorTestActor(Name, probe))); + + // Send persist command followed immediately by another command + actor.Tell("persist"); + actor.Tell("other"); + + // Wait for the other command to be processed (after completion) + await probe.ExpectMsgAsync("other-processed"); + + actor.Tell("get-order"); + var order = await ExpectMsgAsync(); + + // The "other" command should be processed after the completion callback + order.Should().BeEquivalentTo(new[] + { + "persist-start", + "persist-end", + "handler:a", + "handler:b", + "completion", + "other-command" + }, options => options.WithStrictOrdering()); + } + + [Fact(DisplayName = "PersistAll with empty events and sync completion should invoke completion callback immediately")] + public async Task PersistAll_WithEmptyEvents_SyncCompletion_Should_InvokeCompletionImmediately() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new EmptyEventsWithCompletionActor(Name, probe))); + + actor.Tell("persist-empty-sync"); + await probe.ExpectMsgAsync("completed"); + + actor.Tell("check"); + var completionCalled = await ExpectMsgAsync(); + completionCalled.Should().BeTrue(); + } + + [Fact(DisplayName = "PersistAll with empty events and async completion should invoke completion callback immediately")] + public async Task PersistAll_WithEmptyEvents_AsyncCompletion_Should_InvokeCompletionImmediately() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new EmptyEventsWithCompletionActor(Name, probe))); + + actor.Tell("persist-empty-async"); + await probe.ExpectMsgAsync("completed"); + + actor.Tell("check"); + var completionCalled = await ExpectMsgAsync(); + completionCalled.Should().BeTrue(); + } + + [Fact(DisplayName = "PersistAllAsync with empty events and sync completion should invoke completion callback immediately")] + public async Task PersistAllAsync_WithEmptyEvents_SyncCompletion_Should_InvokeCompletionImmediately() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new EmptyEventsWithCompletionActor(Name, probe))); + + actor.Tell("persist-async-empty-sync"); + await probe.ExpectMsgAsync("completed"); + + actor.Tell("check"); + var completionCalled = await ExpectMsgAsync(); + completionCalled.Should().BeTrue(); + } + + [Fact(DisplayName = "PersistAllAsync with empty events and async completion should invoke completion callback immediately")] + public async Task PersistAllAsync_WithEmptyEvents_AsyncCompletion_Should_InvokeCompletionImmediately() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new EmptyEventsWithCompletionActor(Name, probe))); + + actor.Tell("persist-async-empty-async"); + await probe.ExpectMsgAsync("completed"); + + actor.Tell("check"); + var completionCalled = await ExpectMsgAsync(); + completionCalled.Should().BeTrue(); + } + + [Fact(DisplayName = "Persist followed by PersistAll with empty events should maintain execution order")] + public async Task Persist_ThenEmptyPersistAll_Should_MaintainOrder() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new SequentialPersistOrderingActor(Name, probe))); + + actor.Tell("persist-then-empty"); + await probe.ExpectMsgAsync("done"); + + actor.Tell("get-order"); + var order = await ExpectMsgAsync(); + + // The empty PersistAll completion must run AFTER the Persist handler + order.Should().BeEquivalentTo(new[] + { + "persist-handler:first", + "empty-completion" + }, options => options.WithStrictOrdering()); + } + + [Fact(DisplayName = "Sequential PersistAll with empty events in middle should maintain execution order")] + public async Task SequentialPersistAll_WithEmptyInMiddle_Should_MaintainOrder() + { + var probe = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => + new SequentialPersistOrderingActor(Name, probe))); + + actor.Tell("persist-empty-persist"); + await probe.ExpectMsgAsync("done"); + + actor.Tell("get-order"); + var order = await ExpectMsgAsync(); + + // All callbacks should execute in the order they were queued + order.Should().BeEquivalentTo(new[] + { + "first-handler:first", + "first-completion", + "empty-completion", + "last-handler:last", + "last-completion" + }, options => options.WithStrictOrdering()); + } + + #endregion + } +} diff --git a/src/core/Akka.Persistence.Tests/SnapshotStoreHealthCheckSpec.cs b/src/core/Akka.Persistence.Tests/SnapshotStoreHealthCheckSpec.cs new file mode 100644 index 00000000000..51685948614 --- /dev/null +++ b/src/core/Akka.Persistence.Tests/SnapshotStoreHealthCheckSpec.cs @@ -0,0 +1,128 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading; +using System.Threading.Tasks; +using Akka.Configuration; +using Akka.Persistence.Snapshot; +using Akka.TestKit; +using Akka.TestKit.Configs; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Persistence.Tests; + +public class SnapshotStoreHealthCheckSpec : PersistenceSpec +{ + private static Config HealthCheckConfig() + { + const string extraConfig = """ + + akka.persistence.snapshot-store.failing-open { + class = "Akka.Persistence.Tests.FailingSnapshotStore, Akka.Persistence.Tests" + circuit-breaker { + max-failures = 1 + call-timeout = 1s + reset-timeout = 10s + } + } + akka.persistence.snapshot-store.failing-half-open { + class = "Akka.Persistence.Tests.FailingSnapshotStore, Akka.Persistence.Tests" + circuit-breaker { + max-failures = 1 + call-timeout = 1s + reset-timeout = 1s + } + } + # Disable message serialization for circuit breaker tests to avoid serialization issues + akka.actor.serialize-messages = off + + """; + return TestConfigs.TestSchedulerConfig + .WithFallback(Configuration("SnapshotStoreHealthCheckSpec", extraConfig: extraConfig)); + } + + public SnapshotStoreHealthCheckSpec(ITestOutputHelper output) : base(HealthCheckConfig(), output) + { + } + + [Theory] + [InlineData(null)] // default plugin + [InlineData("akka.persistence.snapshot-store.inmem")] + public async Task SnapshotStoreHealthCheck_should_default_to_Healthy(string? pluginId) + { + using var cts = new CancellationTokenSource(RemainingOrDefault); + var pluginHealth = await Extension.CheckSnapshotStoreHealthAsync(pluginId, cts.Token); + + Assert.Equal(PersistenceHealthStatus.Healthy, pluginHealth.Status); + Assert.NotNull(pluginHealth.Description); + } + + [Fact] + public async Task SnapshotStoreHealthCheck_should_return_Degraded_when_CircuitBreaker_is_Open() + { + // Get the snapshot store actor reference + var snapshotStore = Extension.SnapshotStoreFor("akka.persistence.snapshot-store.failing-open"); + + // Trigger a failure to open the circuit breaker + var saveMsg = new SaveSnapshot(new SnapshotMetadata("test-pid", 1, DateTime.UtcNow), "test-snapshot"); + snapshotStore.Tell(saveMsg, TestActor); + + // Advance time to let the save fail and circuit breaker open + var testScheduler = (TestScheduler)Sys.Scheduler; + testScheduler.Advance(TimeSpan.FromSeconds(2)); + + using var cts = new CancellationTokenSource(RemainingOrDefault); + var pluginHealth = await Extension.CheckSnapshotStoreHealthAsync("akka.persistence.snapshot-store.failing-open", cts.Token); + + Assert.Equal(PersistenceHealthStatus.Degraded, pluginHealth.Status); + Assert.Contains("Circuit breaker is open", pluginHealth.Description); + } + + [Fact] + public async Task SnapshotStoreHealthCheck_should_return_Degraded_when_CircuitBreaker_is_HalfOpen() + { + // Get the snapshot store actor reference + var snapshotStore = Extension.SnapshotStoreFor("akka.persistence.snapshot-store.failing-half-open"); + + // Trigger a failure to open the circuit breaker + var saveMsg = new SaveSnapshot(new SnapshotMetadata("test-pid", 1, DateTime.UtcNow), "test-snapshot"); + snapshotStore.Tell(saveMsg, TestActor); + + var testScheduler = (TestScheduler)Sys.Scheduler; + + // Advance time past call-timeout to let the save fail and circuit breaker open + testScheduler.Advance(TimeSpan.FromSeconds(1)); + + // Give the async operations time to complete + await Task.Delay(100); + + // Advance time past reset-timeout to transition to half-open + testScheduler.Advance(TimeSpan.FromSeconds(1)); + + // Give the transition time to complete + await Task.Delay(100); + + using var cts = new CancellationTokenSource(RemainingOrDefault); + var pluginHealth = await Extension.CheckSnapshotStoreHealthAsync("akka.persistence.snapshot-store.failing-half-open", cts.Token); + + Assert.Equal(PersistenceHealthStatus.Degraded, pluginHealth.Status); + Assert.Contains("Circuit breaker is half-open", pluginHealth.Description); + } +} + +/// +/// Test snapshot store that always fails saves to trigger circuit breaker +/// +public class FailingSnapshotStore : LocalSnapshotStore +{ + protected override Task SaveAsync(SnapshotMetadata metadata, object snapshot, CancellationToken cancellationToken) + { + throw new InvalidOperationException("Simulated snapshot store save failure"); + } +} \ No newline at end of file diff --git a/src/core/Akka.Persistence/Eventsourced.Recovery.cs b/src/core/Akka.Persistence/Eventsourced.Recovery.cs index 4a1a1c59d5a..34600abbc30 100644 --- a/src/core/Akka.Persistence/Eventsourced.Recovery.cs +++ b/src/core/Akka.Persistence/Eventsourced.Recovery.cs @@ -383,7 +383,7 @@ private EventsourcedState PersistingEvents() // enables an early return to `processingCommands`, because if this counter hits `0`, // we know the remaining pendingInvocations are all `persistAsync` created, which // means we can go back to processing commands also - and these callbacks will be called as soon as possible - if (invocation is StashingHandlerInvocation) + if (invocation is IStashingInvocation) _pendingStashingPersistInvocations--; if (_pendingStashingPersistInvocations == 0) @@ -398,15 +398,54 @@ private EventsourcedState PersistingEvents() }); } - private void PeekApplyHandler(object payload) + /// + /// Applies the handler for the first pending invocation. + /// For sync handlers, invokes directly. For async handlers, uses RunTask. + /// + /// The event payload to pass to the handler. + /// Callback invoked when the handler completes (true if error). + private void PeekApplyHandler(object payload, Action onComplete) { - try + var invocation = _pendingInvocations.First.Value; + + if (invocation is IAsyncHandlerInvocation asyncInv) { - _pendingInvocations.First.Value.Handler(payload); + // Async handler - run via RunTask + RunTask(async () => + { + try + { + await asyncInv.AsyncHandler(payload); + onComplete(false); + } + catch + { + onComplete(true); + throw; + } + finally + { + FlushBatch(); + } + }); } - finally + else if (invocation is ISyncHandlerInvocation syncInv) { - FlushBatch(); + // Sync handler - invoke directly + try + { + syncInv.Handler(payload); + onComplete(false); + } + catch + { + onComplete(true); + throw; + } + finally + { + FlushBatch(); + } } } @@ -421,16 +460,7 @@ private bool CommonProcessingStateBehavior(object message, Action onWriteM if (m1.ActorInstanceId == _instanceId) { UpdateLastSequenceNr(m1.Persistent); - try - { - PeekApplyHandler(m1.Persistent.Payload); - onWriteMessageComplete(false); - } - catch - { - onWriteMessageComplete(true); - throw; - } + PeekApplyHandler(m1.Persistent.Payload, onWriteMessageComplete); } break; @@ -469,16 +499,7 @@ private bool CommonProcessingStateBehavior(object message, Action onWriteM { if (m.ActorInstanceId == _instanceId) { - try - { - PeekApplyHandler(m.Message); - onWriteMessageComplete(false); - } - catch (Exception) - { - onWriteMessageComplete(true); - throw; - } + PeekApplyHandler(m.Message, onWriteMessageComplete); } break; diff --git a/src/core/Akka.Persistence/Eventsourced.cs b/src/core/Akka.Persistence/Eventsourced.cs index 252addaf197..8e1d8ca6ce2 100644 --- a/src/core/Akka.Persistence/Eventsourced.cs +++ b/src/core/Akka.Persistence/Eventsourced.cs @@ -16,16 +16,43 @@ namespace Akka.Persistence { - public interface IPendingHandlerInvocation + /// + /// Base interface for pending handler invocations. + /// + internal interface IPendingHandlerInvocation { object Event { get; } + } + + /// + /// Interface for invocations with synchronous handlers. + /// + internal interface ISyncHandlerInvocation : IPendingHandlerInvocation + { Action Handler { get; } } + /// + /// Interface for invocations with asynchronous handlers. + /// + internal interface IAsyncHandlerInvocation : IPendingHandlerInvocation + { + Func AsyncHandler { get; } + } + + /// + /// Marker interface for stashing invocations that increment the stashing counter. + /// + internal interface IStashingInvocation : IPendingHandlerInvocation + { + } + /// /// Forces actor to stash incoming commands until all invocations are handled. + /// Used by and + /// . /// - public sealed class StashingHandlerInvocation : IPendingHandlerInvocation + internal sealed class StashingHandlerInvocation : ISyncHandlerInvocation, IStashingInvocation { public StashingHandlerInvocation(object evt, Action handler) { @@ -34,16 +61,32 @@ public StashingHandlerInvocation(object evt, Action handler) } public object Event { get; } - public Action Handler { get; } } + /// + /// Stashing invocation with an asynchronous handler. + /// Used by and + /// . + /// + internal sealed class StashingAsyncHandlerInvocation : IAsyncHandlerInvocation, IStashingInvocation + { + public StashingAsyncHandlerInvocation(object evt, Func asyncHandler) + { + Event = evt; + AsyncHandler = asyncHandler; + } + + public object Event { get; } + public Func AsyncHandler { get; } + } + /// /// Unlike this one does not force actor to stash commands. /// Originates from - /// or method calls. + /// or method calls. /// - public sealed class AsyncHandlerInvocation : IPendingHandlerInvocation + internal sealed class AsyncHandlerInvocation : ISyncHandlerInvocation { public AsyncHandlerInvocation(object evt, Action handler) { @@ -52,10 +95,26 @@ public AsyncHandlerInvocation(object evt, Action handler) } public object Event { get; } - public Action Handler { get; } } + /// + /// Non-stashing invocation with an asynchronous handler. + /// Used by and + /// . + /// + internal sealed class AsyncAsyncHandlerInvocation : IAsyncHandlerInvocation + { + public AsyncAsyncHandlerInvocation(object evt, Func asyncHandler) + { + Event = evt; + AsyncHandler = asyncHandler; + } + + public object Event { get; } + public Func AsyncHandler { get; } + } + /// /// Message used to detect that recovery timed out. /// @@ -310,6 +369,27 @@ public void Persist(TEvent @event, Action handler) sequenceNr: NextSequenceNr(), writerGuid: _writerGuid, sender: Sender))); } + /// + /// Asynchronously persists an with an async handler. + /// This method guarantees that no new commands will be received by a persistent actor + /// between a call to and execution of its handler. + /// + /// The event type. + /// The event to persist. + /// The async handler to invoke after persistence. + public void Persist(TEvent @event, Func handler) + { + if (IsRecovering) + { + throw new InvalidOperationException("Cannot persist during replay. Events can be persisted when receiving RecoveryCompleted or later."); + } + + _pendingStashingPersistInvocations++; + _pendingInvocations.AddLast(new StashingAsyncHandlerInvocation(@event, o => handler((TEvent)o))); + _eventBatch.AddLast(new AtomicWrite(new Persistent(@event, persistenceId: PersistenceId, + sequenceNr: NextSequenceNr(), writerGuid: _writerGuid, sender: Sender))); + } + /// /// Asynchronously persists series of in specified order. /// This is equivalent of multiple calls of calls @@ -341,6 +421,129 @@ public void PersistAll(IEnumerable events, Action handle _eventBatch.AddLast(new AtomicWrite(persistents.ToImmutable())); } + /// + /// Asynchronously persists series of in specified order with a completion callback. + /// The callback is invoked after all events have been persisted and their handlers executed. + /// This method guarantees that no new commands will be received until all handlers and the completion callback have finished. + /// + /// The event type. + /// The events to persist. + /// The handler to invoke for each persisted event. + /// The callback to invoke after all events have been persisted and handled. + public void PersistAll(IEnumerable events, Action handler, Action onComplete) + { + if (events == null || !events.Any()) + { + if (onComplete != null) + Defer(null, _ => onComplete()); + return; + } + + PersistAll(events, handler); + if (onComplete != null) + Defer(null, _ => onComplete()); + } + + /// + /// Asynchronously persists series of in specified order with an async completion callback. + /// The callback is invoked after all events have been persisted and their handlers executed. + /// This method guarantees that no new commands will be received until all handlers and the completion callback have finished. + /// + /// The event type. + /// The events to persist. + /// The handler to invoke for each persisted event. + /// The async callback to invoke after all events have been persisted and handled. + public void PersistAll(IEnumerable events, Action handler, Func onCompleteAsync) + { + if (events == null || !events.Any()) + { + if (onCompleteAsync != null) + Defer(null, async _ => await onCompleteAsync()); + return; + } + + PersistAll(events, handler); + if (onCompleteAsync != null) + Defer(null, async _ => await onCompleteAsync()); + } + + /// + /// Asynchronously persists series of with an async handler. + /// This method guarantees that no new commands will be received until all handlers have finished. + /// + /// The event type. + /// The events to persist. + /// The async handler to invoke for each persisted event. + public void PersistAll(IEnumerable events, Func handler) + { + if (IsRecovering) + { + throw new InvalidOperationException("Cannot persist during replay. Events can be persisted when receiving RecoveryCompleted or later."); + } + + if (events == null) return; + + Func Inv(Func h) => o => h((TEvent)o); + var asyncInv = Inv(handler); + var persistents = ImmutableList.CreateBuilder(); + foreach (var @event in events) + { + _pendingStashingPersistInvocations++; + _pendingInvocations.AddLast(new StashingAsyncHandlerInvocation(@event, asyncInv)); + persistents.Add(new Persistent(@event, persistenceId: PersistenceId, + sequenceNr: NextSequenceNr(), writerGuid: _writerGuid, sender: Sender)); + } + + if (persistents.Count > 0) + _eventBatch.AddLast(new AtomicWrite(persistents.ToImmutable())); + } + + /// + /// Asynchronously persists series of with an async handler and completion callback. + /// The callback is invoked after all events have been persisted and their handlers executed. + /// This method guarantees that no new commands will be received until all handlers and the completion callback have finished. + /// + /// The event type. + /// The events to persist. + /// The async handler to invoke for each persisted event. + /// The callback to invoke after all events have been persisted and handled. + public void PersistAll(IEnumerable events, Func handler, Action onComplete) + { + if (events == null || !events.Any()) + { + if (onComplete != null) + Defer(null, _ => onComplete()); + return; + } + + PersistAll(events, handler); + if (onComplete != null) + Defer(null, _ => onComplete()); + } + + /// + /// Asynchronously persists series of with an async handler and async completion callback. + /// The callback is invoked after all events have been persisted and their handlers executed. + /// This method guarantees that no new commands will be received until all handlers and the completion callback have finished. + /// + /// The event type. + /// The events to persist. + /// The async handler to invoke for each persisted event. + /// The async callback to invoke after all events have been persisted and handled. + public void PersistAll(IEnumerable events, Func handler, Func onCompleteAsync) + { + if (events == null || !events.Any()) + { + if (onCompleteAsync != null) + Defer(null, async _ => await onCompleteAsync()); + return; + } + + PersistAll(events, handler); + if (onCompleteAsync != null) + Defer(null, async _ => await onCompleteAsync()); + } + /// /// Asynchronously persists an . On successful persistence, the /// is called with the persisted event. Unlike method, @@ -381,6 +584,26 @@ public void PersistAsync(TEvent @event, Action handler) sequenceNr: NextSequenceNr(), writerGuid: _writerGuid, sender: Sender))); } + /// + /// Asynchronously persists an with an async handler. + /// Unlike , this method allows + /// commands to be processed between the persist call and handler execution. + /// + /// The event type. + /// The event to persist. + /// The async handler to invoke after persistence. + public void PersistAsync(TEvent @event, Func handler) + { + if (IsRecovering) + { + throw new InvalidOperationException("Cannot persist during replay. Events can be persisted when receiving RecoveryCompleted or later."); + } + + _pendingInvocations.AddLast(new AsyncAsyncHandlerInvocation(@event, o => handler((TEvent)o))); + _eventBatch.AddLast(new AtomicWrite(new Persistent(@event, persistenceId: PersistenceId, + sequenceNr: NextSequenceNr(), writerGuid: _writerGuid, sender: Sender))); + } + /// /// Asynchronously persists series of in specified order. /// This is equivalent of multiple calls of calls @@ -408,12 +631,136 @@ public void PersistAllAsync(IEnumerable events, Action h .ToImmutableList())); } + /// + /// Asynchronously persists series of in specified order with a completion callback. + /// Unlike , this method allows + /// commands to be processed between event handler executions. + /// The callback is invoked after all events have been persisted and their handlers executed. + /// + /// The event type. + /// The events to persist. + /// The handler to invoke for each persisted event. + /// The callback to invoke after all events have been persisted and handled. + public void PersistAllAsync(IEnumerable events, Action handler, Action onComplete) + { + if (events == null || !events.Any()) + { + if (onComplete != null) + DeferAsync(null, _ => onComplete()); + return; + } + + PersistAllAsync(events, handler); + if (onComplete != null) + DeferAsync(null, _ => onComplete()); + } + + /// + /// Asynchronously persists series of in specified order with an async completion callback. + /// Unlike , this method allows + /// commands to be processed between event handler executions. + /// The callback is invoked after all events have been persisted and their handlers executed. + /// + /// The event type. + /// The events to persist. + /// The handler to invoke for each persisted event. + /// The async callback to invoke after all events have been persisted and handled. + public void PersistAllAsync(IEnumerable events, Action handler, Func onCompleteAsync) + { + if (events == null || !events.Any()) + { + if (onCompleteAsync != null) + DeferAsync(null, async _ => await onCompleteAsync()); + return; + } + + PersistAllAsync(events, handler); + if (onCompleteAsync != null) + DeferAsync(null, async _ => await onCompleteAsync()); + } + + /// + /// Asynchronously persists series of with an async handler. + /// Unlike , this method allows + /// commands to be processed between event handler executions. + /// + /// The event type. + /// The events to persist. + /// The async handler to invoke for each persisted event. + public void PersistAllAsync(IEnumerable events, Func handler) + { + if (IsRecovering) + { + throw new InvalidOperationException("Cannot persist during replay. Events can be persisted when receiving RecoveryCompleted or later."); + } + + Func Inv(Func h) => o => h((TEvent)o); + var asyncInv = Inv(handler); + var enumerable = events as TEvent[] ?? events.ToArray(); + foreach (var @event in enumerable) + { + _pendingInvocations.AddLast(new AsyncAsyncHandlerInvocation(@event, asyncInv)); + } + + _eventBatch.AddLast(new AtomicWrite(enumerable.Select(e => new Persistent(e, persistenceId: PersistenceId, + sequenceNr: NextSequenceNr(), writerGuid: _writerGuid, sender: Sender)) + .ToImmutableList())); + } + + /// + /// Asynchronously persists series of with an async handler and completion callback. + /// Unlike , this method allows + /// commands to be processed between event handler executions. + /// The callback is invoked after all events have been persisted and their handlers executed. + /// + /// The event type. + /// The events to persist. + /// The async handler to invoke for each persisted event. + /// The callback to invoke after all events have been persisted and handled. + public void PersistAllAsync(IEnumerable events, Func handler, Action onComplete) + { + if (events == null || !events.Any()) + { + if (onComplete != null) + DeferAsync(null, _ => onComplete()); + return; + } + + PersistAllAsync(events, handler); + if (onComplete != null) + DeferAsync(null, _ => onComplete()); + } + + /// + /// Asynchronously persists series of with an async handler and async completion callback. + /// Unlike , this method allows + /// commands to be processed between event handler executions. + /// The callback is invoked after all events have been persisted and their handlers executed. + /// + /// The event type. + /// The events to persist. + /// The async handler to invoke for each persisted event. + /// The async callback to invoke after all events have been persisted and handled. + public void PersistAllAsync(IEnumerable events, Func handler, Func onCompleteAsync) + { + if (events == null || !events.Any()) + { + if (onCompleteAsync != null) + DeferAsync(null, async _ => await onCompleteAsync()); + return; + } + + PersistAllAsync(events, handler); + if (onCompleteAsync != null) + DeferAsync(null, async _ => await onCompleteAsync()); + } + /// /// Defer the execution until all pending handlers have been executed. /// Allows to define logic within the actor, which will respect the invocation-order-guarantee /// in respect to calls. /// That is, if was invoked before - /// , the corresponding handlers will be + /// , the corresponding handlers will be /// invoked in the same order as they were registered in. /// /// This call will NOT result in being persisted, use @@ -447,6 +794,79 @@ public void DeferAsync(TEvent evt, Action handler) } } + /// + /// Defer the execution until all pending handlers have been executed. + /// This is the async variant that accepts an async handler. + /// + /// This call will NOT result in being persisted. + /// + /// If there are no pending persist handler calls, the will be called immediately + /// via . + /// + /// If persistence of an earlier event fails, the persistent actor will stop, and the + /// will not be run. + /// + /// The event type. + /// The event to pass to the handler. + /// The async handler to invoke. + public void DeferAsync(TEvent evt, Func handler) + { + if (IsRecovering) + { + throw new InvalidOperationException("Cannot persist during replay. Events can be persisted when receiving RecoveryCompleted or later."); + } + + if (_pendingInvocations.Count == 0) + { + RunTask(() => handler(evt)); + } + else + { + _pendingInvocations.AddLast(new AsyncAsyncHandlerInvocation(evt, o => handler((TEvent)o))); + _eventBatch.AddLast(new NonPersistentMessage(evt, Sender)); + } + } + + /// + /// Internal stashing variant of Defer. Increments _pendingStashingPersistInvocations + /// to ensure commands remain stashed until this handler completes. + /// Used internally for completion callbacks on . + /// + /// The event type. + /// The event to pass to the handler. + /// The handler to invoke. + internal void Defer(TEvent evt, Action handler) + { + if (IsRecovering) + { + throw new InvalidOperationException("Cannot persist during replay. Events can be persisted when receiving RecoveryCompleted or later."); + } + + _pendingStashingPersistInvocations++; + _pendingInvocations.AddLast(new StashingHandlerInvocation(evt, o => handler((TEvent)o))); + _eventBatch.AddLast(new NonPersistentMessage(evt, Sender)); + } + + /// + /// Internal stashing variant of Defer with async handler. Increments _pendingStashingPersistInvocations + /// to ensure commands remain stashed until this handler completes. + /// Used internally for async completion callbacks on . + /// + /// The event type. + /// The event to pass to the handler. + /// The async handler to invoke. + internal void Defer(TEvent evt, Func handler) + { + if (IsRecovering) + { + throw new InvalidOperationException("Cannot persist during replay. Events can be persisted when receiving RecoveryCompleted or later."); + } + + _pendingStashingPersistInvocations++; + _pendingInvocations.AddLast(new StashingAsyncHandlerInvocation(evt, o => handler((TEvent)o))); + _eventBatch.AddLast(new NonPersistentMessage(evt, Sender)); + } + /// /// Permanently deletes all persistent messages with sequence numbers less than or equal . /// If the delete is successful a will be sent to the actor. diff --git a/src/core/Akka.Persistence/Journal/AsyncWriteJournal.cs b/src/core/Akka.Persistence/Journal/AsyncWriteJournal.cs index 779ccdff36e..4939d2cd342 100644 --- a/src/core/Akka.Persistence/Journal/AsyncWriteJournal.cs +++ b/src/core/Akka.Persistence/Journal/AsyncWriteJournal.cs @@ -9,7 +9,6 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Akka.Actor; @@ -25,6 +24,7 @@ public abstract class AsyncWriteJournal : WriteJournalBase, IAsyncRecovery { protected readonly bool CanPublish; private readonly CircuitBreaker _breaker; + private readonly ReplayFilterMode _replayFilterMode; private readonly bool _isReplayFilterEnabled; private readonly int _replayFilterWindowSize; @@ -32,6 +32,8 @@ public abstract class AsyncWriteJournal : WriteJournalBase, IAsyncRecovery private readonly bool _replayDebugEnabled; private readonly IActorRef _resequencer; + private readonly IReadOnlyDictionary _defaultHealthCheckTags; + private long _resequencerCounter = 1L; /// @@ -84,6 +86,26 @@ protected AsyncWriteJournal() _replayDebugEnabled = config.GetBoolean("replay-filter.debug", false); _resequencer = Context.ActorOf(Props.Create(() => new Resequencer()), "resequencer"); + _defaultHealthCheckTags = new Dictionary + { + { "journal", Self.Path.Name } + }; + } + + /// + /// Health check for the journal. + /// + /// Cancellation token for the health check invocation. + /// A with a health status and optional error message. + public virtual Task CheckHealthAsync(CancellationToken cancellationToken = default) + { + if(_breaker.IsHalfOpen) + return Task.FromResult(new PersistenceHealthCheckResult(PersistenceHealthStatus.Degraded, + $"Circuit breaker is half-open, some operations may be failing intermittently", _breaker.LastCaughtException, _defaultHealthCheckTags)); + if(_breaker.IsOpen) + return Task.FromResult(new PersistenceHealthCheckResult(PersistenceHealthStatus.Degraded, + $"Circuit breaker is open, some operations may be failing intermittently", _breaker.LastCaughtException, _defaultHealthCheckTags)); + return Task.FromResult(new PersistenceHealthCheckResult(PersistenceHealthStatus.Healthy, Description:"Ok", Data: _defaultHealthCheckTags)); } /// @@ -162,7 +184,7 @@ protected AsyncWriteJournal() /// /// This call is protected with a circuit-breaker. /// - /// TBD + /// The set of messages to write. /// used to signal cancelled snapshot operation protected abstract Task> WriteMessagesAsync(IEnumerable messages, CancellationToken cancellationToken); @@ -170,8 +192,8 @@ protected AsyncWriteJournal() /// Asynchronously deletes all persistent messages up to inclusive /// bound. /// - /// TBD - /// TBD + /// The id of the entity. + /// The inclusive upper-bound of sequence numbers to delete. /// used to signal cancelled snapshot operation protected abstract Task DeleteMessagesToAsync(string persistenceId, long toSequenceNr, CancellationToken cancellationToken); @@ -179,8 +201,8 @@ protected AsyncWriteJournal() /// Plugin API: Allows plugin implementers to use f.PipeTo(Self) /// and handle additional messages for implementing advanced features /// - /// TBD - /// TBD + /// The message to receive + /// true if the message was handled, false otherwise. protected virtual bool ReceivePluginInternal(object message) { return false; @@ -205,6 +227,18 @@ protected bool ReceiveWriteJournal(object message) case DeleteMessagesTo deleteMessagesTo: HandleDeleteMessagesTo(deleteMessagesTo); return true; + case CheckJournalHealth checkHealth: + var sender = Sender; + CheckHealthAsync(checkHealth.CancellationToken) + // PipeTo implementation no longer requires a closure, but better safe than sorry + .PipeTo(sender, + success: result => new JournalHealthCheckResponse(result), + failure: ex => new JournalHealthCheckResponse( + new PersistenceHealthCheckResult(PersistenceHealthStatus.Unhealthy, + "Encountered an exception while performing health check", + ex, + _defaultHealthCheckTags))); + return true; default: return false; } @@ -256,16 +290,6 @@ private void HandleReplayMessages(ReplayMessages message) async Task ExecuteHighestSequenceNr() { - void CompleteHighSeqNo(long highSeqNo) - { - replyTo.Tell(new RecoverySuccess(highSeqNo)); - - if (CanPublish) - { - eventStream.Publish(message); - } - } - try { var highSequenceNr = await _breaker.WithCircuitBreaker((message, readHighestSequenceNrFrom, awj: this), (state, ct) => @@ -306,6 +330,18 @@ await ReplayMessagesAsync(context, message.PersistenceId, message.FromSequenceNr { replyTo.Tell(new ReplayMessagesFailure(TryUnwrapException(ex))); } + + return; + + void CompleteHighSeqNo(long highSeqNo) + { + replyTo.Tell(new RecoverySuccess(highSeqNo)); + + if (CanPublish) + { + eventStream.Publish(message); + } + } } // instead of ContinueWith @@ -315,10 +351,12 @@ await ReplayMessagesAsync(context, message.PersistenceId, message.FromSequenceNr } /// - /// TBD + /// INTERNAL API. + /// + /// used to flatten aggregate exceptions. /// - /// TBD - /// TBD + /// The input exception. + /// A possibly flattened exception. protected static Exception TryUnwrapException(Exception e) { if (e is not AggregateException aggregateException) return e; @@ -371,7 +409,7 @@ private async Task ExecuteBatch(WriteMessages message, int atomicWriteCount, IAc } } - private void ProcessResults(IImmutableList results, int atomicWriteCount, WriteMessages writeMessage, IActorRef resequencer, + private static void ProcessResults(IImmutableList results, int atomicWriteCount, WriteMessages writeMessage, IActorRef resequencer, long resequencerCounter, IActorRef writeJournal) { // there should be no circumstances under which `writeResult` can be `null` @@ -385,11 +423,11 @@ private void ProcessResults(IImmutableList results, int atomicWriteCo : new WriteMessageRejected(x, exception, writeMessage.ActorInstanceId), results, resequencerCounter, writeMessage, resequencer, writeJournal); } - private void Resequence(Func mapper, + private static void Resequence(Func mapper, IImmutableList results, long resequencerCounter, WriteMessages msg, IActorRef resequencer, IActorRef writeJournal) { var i = 0; - var enumerator = results?.GetEnumerator(); + using var enumerator = results?.GetEnumerator(); foreach (var resequencable in msg.Messages) { if (resequencable is AtomicWrite aw) diff --git a/src/core/Akka.Persistence/JournalProtocol.cs b/src/core/Akka.Persistence/JournalProtocol.cs index d97bfb3f208..66c98e69199 100644 --- a/src/core/Akka.Persistence/JournalProtocol.cs +++ b/src/core/Akka.Persistence/JournalProtocol.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Threading; using Akka.Actor; using Akka.Event; @@ -180,6 +181,42 @@ public override int GetHashCode() public override string ToString() => $"DeleteMessagesTo"; } + /// + /// Invokes a health check on the journal plugin. + /// + public sealed class CheckJournalHealth : IJournalRequest + { + public CheckJournalHealth(CancellationToken cancellationToken) + { + CancellationToken = cancellationToken; + } + + public CancellationToken CancellationToken { get; } + + public override string ToString() + { + return "CheckJournalHealth"; + } + } + + /// + /// Health check response from the journal. + /// + public sealed class JournalHealthCheckResponse : IJournalResponse + { + public JournalHealthCheckResponse(PersistenceHealthCheckResult result) + { + Result = result; + } + + public PersistenceHealthCheckResult Result { get; } + + public override string ToString() + { + return $"JournalHealthCheckResponse<{Result}>"; + } + } + /// /// Request to write messages. /// diff --git a/src/core/Akka.Persistence/Persistence.cs b/src/core/Akka.Persistence/Persistence.cs index d6d8f052719..6a6b4fd613b 100644 --- a/src/core/Akka.Persistence/Persistence.cs +++ b/src/core/Akka.Persistence/Persistence.cs @@ -9,6 +9,7 @@ using System.Collections.Concurrent; using System.Linq; using System.Threading; +using System.Threading.Tasks; using Akka.Actor; using Akka.Annotations; using Akka.Configuration; @@ -252,6 +253,40 @@ public IActorRef JournalFor(string journalPluginId) return PluginHolderFor(configPath, JournalFallbackConfigPath).Ref; } + /// + /// Shortcut for invoking journal health checks. + /// + /// The HOCON id of the Akka.Persistence plugin./ + /// An optional cancellation token. + /// A with health status and possibly a descriptive message. + public async Task CheckJournalHealthAsync(string journalPluginId, + CancellationToken cancellationToken = default) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(Settings.AskTimeout); + + var pluginRef = JournalFor(journalPluginId); + var r = await pluginRef.Ask(new CheckJournalHealth(timeoutCts.Token), timeoutCts.Token); + return r.Result; + } + + /// + /// Shortcut for invoking snapshot store health checks. + /// + /// The HOCON id of the Akka.Persistence plugin. + /// An optional cancellation token. + /// A with health status and possibly a descriptive message. + public async Task CheckSnapshotStoreHealthAsync(string snapshotStorePluginId, + CancellationToken cancellationToken = default) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(Settings.AskTimeout); + + var pluginRef = SnapshotStoreFor(snapshotStorePluginId); + var r = await pluginRef.Ask(new CheckSnapshotStoreHealth(timeoutCts.Token), timeoutCts.Token); + return r.Result; + } + /// /// Returns a snapshot store plugin actor identified by . /// When empty, looks in `akka.persistence.snapshot-store.plugin` to find configuration entry path. diff --git a/src/core/Akka.Persistence/PersistenceHealthStatus.cs b/src/core/Akka.Persistence/PersistenceHealthStatus.cs new file mode 100644 index 00000000000..587b3d5e4bd --- /dev/null +++ b/src/core/Akka.Persistence/PersistenceHealthStatus.cs @@ -0,0 +1,40 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; + +namespace Akka.Persistence; + +/// +/// Used by SnapshotStore and Journal to indicate the health status of the underlying storage. +/// +public enum PersistenceHealthStatus +{ + /// + /// Akka.Persistence is working as expected. + /// + Healthy = 0, + + /// + /// Akka.Persistence is experiencing some issues that should be recoverable. + /// + Degraded = 1, + + /// + /// Akka.Persistence has experienced a fatal error. + /// + Unhealthy = 2, +} + +/// +/// Results from a health check. +/// +public readonly record struct PersistenceHealthCheckResult(PersistenceHealthStatus Status, + string? Description = null, + Exception? Exception = null, + IReadOnlyDictionary? Data = null); \ No newline at end of file diff --git a/src/core/Akka.Persistence/Snapshot/SnapshotStore.cs b/src/core/Akka.Persistence/Snapshot/SnapshotStore.cs index cde611e7700..f78081fdcd9 100644 --- a/src/core/Akka.Persistence/Snapshot/SnapshotStore.cs +++ b/src/core/Akka.Persistence/Snapshot/SnapshotStore.cs @@ -6,6 +6,7 @@ //----------------------------------------------------------------------- using System; +using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -20,10 +21,11 @@ namespace Akka.Persistence.Snapshot /// public abstract class SnapshotStore : ActorBase { - private readonly TaskContinuationOptions _continuationOptions = TaskContinuationOptions.ExecuteSynchronously; + private const TaskContinuationOptions ContinuationOptions = TaskContinuationOptions.ExecuteSynchronously; private readonly bool _publish; private readonly CircuitBreaker _breaker; private readonly ILoggingAdapter _log; + private readonly IReadOnlyDictionary _defaultHealthCheckTags; /// /// Initializes a new instance of the class. @@ -46,8 +48,28 @@ protected SnapshotStore() config.GetInt("circuit-breaker.max-failures", 10), config.GetTimeSpan("circuit-breaker.call-timeout", TimeSpan.FromSeconds(10)), config.GetTimeSpan("circuit-breaker.reset-timeout", TimeSpan.FromSeconds(30))); - + _log = Context.GetLogger(); + _defaultHealthCheckTags = new Dictionary + { + { "snapshot-store", Self.Path.Name } + }; + } + + /// + /// Health check for the snapshot store. + /// + /// Cancellation token for the health check invocation. + /// A with a health status and optional error message. + public virtual Task CheckHealthAsync(CancellationToken cancellationToken = default) + { + if(_breaker.IsHalfOpen) + return Task.FromResult(new PersistenceHealthCheckResult(PersistenceHealthStatus.Degraded, + $"Circuit breaker is half-open, some operations may be failing intermittently.", _breaker.LastCaughtException, _defaultHealthCheckTags)); + if(_breaker.IsOpen) + return Task.FromResult(new PersistenceHealthCheckResult(PersistenceHealthStatus.Degraded, + $"Circuit breaker is open, some operations may be failing intermittently.", _breaker.LastCaughtException, _defaultHealthCheckTags)); + return Task.FromResult(new PersistenceHealthCheckResult(PersistenceHealthStatus.Healthy, "OK.", Data: _defaultHealthCheckTags)); } /// @@ -74,7 +96,7 @@ private bool ReceiveSnapshotStore(object message) : new LoadSnapshotFailed(t.IsFaulted ? TryUnwrapException(t.Exception) : new OperationCanceledException("LoadAsync canceled, possibly due to timing out.")), - _continuationOptions) + ContinuationOptions) .PipeTo(senderPersistentActor); break; @@ -92,7 +114,7 @@ private bool ReceiveSnapshotStore(object message) t.IsFaulted ? TryUnwrapException(t.Exception) : new OperationCanceledException("SaveAsync canceled, possibly due to timing out.", TryUnwrapException(t.Exception))), - _continuationOptions) + ContinuationOptions) .PipeTo(self, senderPersistentActor); break; @@ -138,13 +160,13 @@ private bool ReceiveSnapshotStore(object message) t.IsFaulted ? TryUnwrapException(t.Exception) : new OperationCanceledException("DeleteAsync canceled, possibly due to timing out.")), - _continuationOptions) + ContinuationOptions) .PipeTo(self, senderPersistentActor) .ContinueWith(_ => { if (_publish) eventStream.Publish(message); - }, _continuationOptions); + }, ContinuationOptions); break; } @@ -180,13 +202,13 @@ private bool ReceiveSnapshotStore(object message) t.IsFaulted ? TryUnwrapException(t.Exception) : new OperationCanceledException("DeleteAsync canceled, possibly due to timing out.")), - _continuationOptions) + ContinuationOptions) .PipeTo(self, senderPersistentActor) .ContinueWith(_ => { if (_publish) eventStream.Publish(message); - }, _continuationOptions); + }, ContinuationOptions); break; } @@ -212,6 +234,17 @@ private bool ReceiveSnapshotStore(object message) } break; + case CheckSnapshotStoreHealth checkHealth: + var sender = Sender; + CheckHealthAsync(checkHealth.CancellationToken) + // PipeTo implementation no longer requires a closure, but better safe than sorry + .PipeTo(sender, + success: result => new SnapshotStoreHealthCheckResponse(result), + failure: ex => new SnapshotStoreHealthCheckResponse( + new PersistenceHealthCheckResult(PersistenceHealthStatus.Unhealthy, + "Encountered exception while performing health check", + ex, _defaultHealthCheckTags))); + break; default: return false; diff --git a/src/core/Akka.Persistence/SnapshotProtocol.cs b/src/core/Akka.Persistence/SnapshotProtocol.cs index 915d09887f5..3ff845b6b4d 100644 --- a/src/core/Akka.Persistence/SnapshotProtocol.cs +++ b/src/core/Akka.Persistence/SnapshotProtocol.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Threading; using Newtonsoft.Json; namespace Akka.Persistence @@ -26,6 +27,39 @@ public interface ISnapshotRequest : ISnapshotMessage { } /// public interface ISnapshotResponse : ISnapshotMessage { } + public sealed class CheckSnapshotStoreHealth : ISnapshotRequest + { + public CheckSnapshotStoreHealth(CancellationToken cancellationToken) + { + CancellationToken = cancellationToken; + } + + public CancellationToken CancellationToken { get; } + + public override string ToString() + { + return "CheckSnapshotStoreHealth"; + } + } + + /// + /// Health check response from the SnapshotStore. + /// + public sealed class SnapshotStoreHealthCheckResponse : ISnapshotResponse + { + public SnapshotStoreHealthCheckResponse(PersistenceHealthCheckResult result) + { + Result = result; + } + + public PersistenceHealthCheckResult Result { get; } + + public override string ToString() + { + return $"SnapshotStoreHealthCheckResponse<{Result}>"; + } + } + /// /// Metadata for all persisted snapshot records. /// diff --git a/src/core/Akka.Remote.Tests/Akka.Remote.Tests.csproj b/src/core/Akka.Remote.Tests/Akka.Remote.Tests.csproj index 409bcace4bb..e06e3349a45 100644 --- a/src/core/Akka.Remote.Tests/Akka.Remote.Tests.csproj +++ b/src/core/Akka.Remote.Tests/Akka.Remote.Tests.csproj @@ -19,6 +19,10 @@ PreserveNewest + + PreserveNewest + + PreserveNewest diff --git a/src/core/Akka.Remote.Tests/RemoteConfigSpec.cs b/src/core/Akka.Remote.Tests/RemoteConfigSpec.cs index f13689b943f..305ae82ac95 100644 --- a/src/core/Akka.Remote.Tests/RemoteConfigSpec.cs +++ b/src/core/Akka.Remote.Tests/RemoteConfigSpec.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Akka.Configuration; using Akka.Remote.Transport.DotNetty; using Akka.TestKit; using Akka.Util.Internal; @@ -113,13 +114,37 @@ public void Remoting_should_contain_correct_heliosTCP_values_in_ReferenceConf() Assert.False(s.EnableSsl); } + [Fact] + public void SSL_should_have_secure_defaults_when_enabled() + { + // Simple test - just enable SSL and check the defaults from reference.conf + var certPath = System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory(), "Resources", "akka-validcert.pfx"); + var config = ConfigurationFactory.ParseString($@" + akka.remote.dot-netty.tcp.enable-ssl = true + akka.remote.dot-netty.tcp.ssl.certificate {{ + path = ""{certPath.Replace("\\", "\\\\")}"" + password = ""password"" + }} + ").WithFallback(RARP.For(Sys).Provider.RemoteSettings.Config); + + var c = config.GetConfig("akka.remote.dot-netty.tcp"); + var s = DotNettyTransportSettings.Create(c); + + // Verify SSL is enabled + Assert.True(s.EnableSsl); + + // Verify secure defaults + Assert.True(s.Ssl.RequireMutualAuthentication, "Mutual TLS should be enabled by default"); + Assert.False(s.Ssl.SuppressValidation, "Certificate validation should not be suppressed by default"); + } + [Fact] public void When_remoting_works_in_Mono_ip_enforcement_should_be_defaulted_to_true() { if (!IsMono) return; // skip IF NOT using Mono var c = RARP.For(Sys).Provider.RemoteSettings.Config.GetConfig("akka.remote.dot-netty.tcp"); var s = DotNettyTransportSettings.Create(c); - + Assert.True(s.EnforceIpFamily); } diff --git a/src/core/Akka.Remote.Tests/Resources/akka-client-cert.pfx b/src/core/Akka.Remote.Tests/Resources/akka-client-cert.pfx new file mode 100644 index 00000000000..5eac2433456 Binary files /dev/null and b/src/core/Akka.Remote.Tests/Resources/akka-client-cert.pfx differ diff --git a/src/core/Akka.Remote.Tests/Transport/CertificateValidationHelpersSpec.cs b/src/core/Akka.Remote.Tests/Transport/CertificateValidationHelpersSpec.cs new file mode 100644 index 00000000000..dfeea03d1f2 --- /dev/null +++ b/src/core/Akka.Remote.Tests/Transport/CertificateValidationHelpersSpec.cs @@ -0,0 +1,246 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Akka.Event; +using Akka.Remote.Transport.DotNetty; +using Akka.TestKit; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Remote.Tests.Transport +{ + /// + /// Unit tests for CertificateValidation helper methods to ensure proper edge case handling + /// + public class CertificateValidationHelpersSpec : AkkaSpec + { + private const string ValidCertPath = "Resources/akka-validcert.pfx"; + private const string Password = "password"; + private readonly ILoggingAdapter _log; + + public CertificateValidationHelpersSpec(ITestOutputHelper output) : base(output) + { + _log = Logging.GetLogger(Sys, typeof(CertificateValidationHelpersSpec)); + } + + #region PinnedCertificate Tests + + [Fact(DisplayName = "PinnedCertificate should reject null certificate")] + public void PinnedCertificate_should_reject_null_certificate() + { + // Arrange + var validator = CertificateValidation.PinnedCertificate("ABCD1234"); + + // Act & Assert + EventFilter.Error(contains: "certificate is null").ExpectOne(() => + { + var result = validator(null, null, "test-peer", SslPolicyErrors.None, _log); + Assert.False(result); + }); + } + + // Note: X509Certificate2 always has a thumbprint when properly constructed, + // so we can't test the empty thumbprint case directly. The null check in + // PinnedCertificate is defensive programming for edge cases. + + [Fact(DisplayName = "PinnedCertificate should throw if no thumbprints provided")] + public void PinnedCertificate_should_throw_if_no_thumbprints_provided() + { + // Act & Assert + Assert.Throws(() => CertificateValidation.PinnedCertificate()); + Assert.Throws(() => CertificateValidation.PinnedCertificate(null)); + Assert.Throws(() => CertificateValidation.PinnedCertificate(new string[0])); + } + + [Fact(DisplayName = "PinnedCertificate should throw if only empty/whitespace thumbprints provided")] + public void PinnedCertificate_should_throw_if_only_empty_thumbprints_provided() + { + // Act & Assert + Assert.Throws(() => CertificateValidation.PinnedCertificate("")); + Assert.Throws(() => CertificateValidation.PinnedCertificate("", " ", null)); + Assert.Throws(() => CertificateValidation.PinnedCertificate(" ", "\t", "\n")); + } + + [Fact(DisplayName = "PinnedCertificate should filter out empty thumbprints and use valid ones")] + public void PinnedCertificate_should_filter_empty_thumbprints() + { + // Arrange + var cert = new X509Certificate2(ValidCertPath, Password); + var thumbprint = cert.Thumbprint; + + // Include some empty/null values that should be filtered out + var validator = CertificateValidation.PinnedCertificate("", thumbprint, null, " ", thumbprint.ToLower()); + + // Act + var result = validator(cert, null, "test-peer", SslPolicyErrors.None, _log); + + // Assert + Assert.True(result); // Should accept because valid thumbprint is in the list + } + + [Fact(DisplayName = "PinnedCertificate should be case-insensitive for thumbprints")] + public void PinnedCertificate_should_be_case_insensitive() + { + // Arrange + var cert = new X509Certificate2(ValidCertPath, Password); + var thumbprint = cert.Thumbprint; + + // Test with lowercase thumbprint in allowed list + var validator = CertificateValidation.PinnedCertificate(thumbprint.ToLower()); + + // Act + var result = validator(cert, null, "test-peer", SslPolicyErrors.None, _log); + + // Assert + Assert.True(result); // Should accept due to case-insensitive comparison + } + + [Fact(DisplayName = "PinnedCertificate should accept certificate with matching thumbprint from multiple allowed")] + public void PinnedCertificate_should_accept_from_multiple_allowed() + { + // Arrange + var cert = new X509Certificate2(ValidCertPath, Password); + var thumbprint = cert.Thumbprint; + + var validator = CertificateValidation.PinnedCertificate( + "1111111111111111111111111111111111111111", + thumbprint, + "2222222222222222222222222222222222222222"); + + // Act + var result = validator(cert, null, "test-peer", SslPolicyErrors.None, _log); + + // Assert + Assert.True(result); + } + + [Fact(DisplayName = "PinnedCertificate should reject certificate with non-matching thumbprint")] + public void PinnedCertificate_should_reject_non_matching_thumbprint() + { + // Arrange + var cert = new X509Certificate2(ValidCertPath, Password); + var validator = CertificateValidation.PinnedCertificate( + "1111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222"); + + // Act & Assert + EventFilter.Error(contains: "not in allowed list").ExpectOne(() => + { + var result = validator(cert, null, "test-peer", SslPolicyErrors.None, _log); + Assert.False(result); + }); + } + + #endregion + + #region ValidateSubject Tests + + [Fact(DisplayName = "ValidateSubject should reject null certificate")] + public void ValidateSubject_should_reject_null_certificate() + { + // Arrange + var validator = CertificateValidation.ValidateSubject("CN=TestSubject"); + + // Act & Assert + EventFilter.Error(contains: "certificate is null").ExpectOne(() => + { + var result = validator(null, null, "test-peer", SslPolicyErrors.None, _log); + Assert.False(result); + }); + } + + [Fact(DisplayName = "ValidateSubject should throw if pattern is null or empty")] + public void ValidateSubject_should_throw_if_pattern_null_or_empty() + { + // Act & Assert + Assert.Throws(() => CertificateValidation.ValidateSubject(null)); + Assert.Throws(() => CertificateValidation.ValidateSubject("")); + Assert.Throws(() => CertificateValidation.ValidateSubject(" ")); + } + + #endregion + + #region ValidateIssuer Tests + + [Fact(DisplayName = "ValidateIssuer should reject null certificate")] + public void ValidateIssuer_should_reject_null_certificate() + { + // Arrange + var validator = CertificateValidation.ValidateIssuer("CN=TestIssuer"); + + // Act & Assert + EventFilter.Error(contains: "certificate is null").ExpectOne(() => + { + var result = validator(null, null, "test-peer", SslPolicyErrors.None, _log); + Assert.False(result); + }); + } + + [Fact(DisplayName = "ValidateIssuer should throw if pattern is null or empty")] + public void ValidateIssuer_should_throw_if_pattern_null_or_empty() + { + // Act & Assert + Assert.Throws(() => CertificateValidation.ValidateIssuer(null)); + Assert.Throws(() => CertificateValidation.ValidateIssuer("")); + Assert.Throws(() => CertificateValidation.ValidateIssuer(" ")); + } + + #endregion + + #region Combine Tests + + [Fact(DisplayName = "Combine should handle null validators array")] + public void Combine_should_handle_null_validators() + { + // Act & Assert - Should throw ArgumentException + Assert.Throws(() => CertificateValidation.Combine(null)); + } + + [Fact(DisplayName = "Combine should handle empty validators array")] + public void Combine_should_handle_empty_validators() + { + // Act & Assert - Should throw ArgumentException + Assert.Throws(() => CertificateValidation.Combine()); + Assert.Throws(() => CertificateValidation.Combine(new CertificateValidationCallback[0])); + } + + [Fact(DisplayName = "Combine should short-circuit on first failure")] + public void Combine_should_short_circuit_on_first_failure() + { + // Arrange + var callCount = 0; + CertificateValidationCallback validator1 = (cert, chain, peer, errors, log) => + { + callCount++; + log.Error("First validator failed"); + return false; // Fail + }; + CertificateValidationCallback validator2 = (cert, chain, peer, errors, log) => + { + callCount++; + log.Error("Second validator should never be reached"); + return true; // This should never be called + }; + + var combined = CertificateValidation.Combine(validator1, validator2); + var cert = new X509Certificate2(ValidCertPath, Password); + + // Act & Assert + EventFilter.Error(contains: "First validator failed").ExpectOne(() => + { + var result = combined(cert, null, "test-peer", SslPolicyErrors.None, _log); + Assert.False(result); + Assert.Equal(1, callCount); // Only first validator should be called - short-circuit behavior + }); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/core/Akka.Remote.Tests/Transport/DotNettyCertificateValidationSpec.cs b/src/core/Akka.Remote.Tests/Transport/DotNettyCertificateValidationSpec.cs new file mode 100644 index 00000000000..b4dcf64c630 --- /dev/null +++ b/src/core/Akka.Remote.Tests/Transport/DotNettyCertificateValidationSpec.cs @@ -0,0 +1,132 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using Akka.Actor; +using Akka.Configuration; +using Akka.TestKit; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Remote.Tests.Transport +{ + /// + /// Tests that SSL certificate validation happens at startup, not during runtime. + /// This ensures fail-fast behavior when certificates are misconfigured. + /// + public class DotNettyCertificateValidationSpec : AkkaSpec + { + private const string ValidCertPath = "Resources/akka-validcert.pfx"; + private const string Password = "password"; + private static readonly string NoKeyCertPath = Path.Combine("Resources", "validation-no-key.cer"); + + public DotNettyCertificateValidationSpec(ITestOutputHelper output) : base(ConfigurationFactory.Empty, output) + { + } + + private static Config CreateConfig(bool enableSsl, string certPath, string certPassword) + { + var baseConfig = ConfigurationFactory.ParseString(@"akka { + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = " + (enableSsl ? "on" : "off") + @" + log-transport = off + } + }"); + + if (!enableSsl || string.IsNullOrEmpty(certPath)) + return baseConfig; + + var escapedPath = certPath.Replace("\\", "\\\\"); + var ssl = $@"akka.remote.dot-netty.tcp.ssl {{ + suppress-validation = on + certificate {{ + path = ""{escapedPath}"" + password = ""{certPassword ?? string.Empty}"" + }} + }}"; + return baseConfig.WithFallback(ssl); + } + + private static void CreateCertificateWithoutPrivateKey() + { + var fullCert = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.Exportable); + var publicKeyBytes = fullCert.Export(X509ContentType.Cert); + var dir = Path.GetDirectoryName(NoKeyCertPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.WriteAllBytes(NoKeyCertPath, publicKeyBytes); + } + + [Fact] + public void Server_should_fail_at_startup_with_certificate_without_private_key() + { + CreateCertificateWithoutPrivateKey(); + + try + { + // Server with cert that has no private key should FAIL TO START + var serverConfig = CreateConfig(true, NoKeyCertPath, null); + + // This should throw an exception during ActorSystem.Create (wrapped in AggregateException) + var aggregateEx = Assert.Throws(() => + { + using var server = ActorSystem.Create("ServerSystem", serverConfig); + }); + + // Unwrap the inner exception + var innerEx = aggregateEx.InnerException ?? aggregateEx; + while (innerEx is AggregateException agg && agg.InnerException != null) + innerEx = agg.InnerException; + + // Should be ConfigurationException about private key + Assert.IsType(innerEx); + Assert.Contains("private key", innerEx.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try + { + if (File.Exists(NoKeyCertPath)) + File.Delete(NoKeyCertPath); + } + catch { /* ignore */ } + } + } + + [Fact] + public void Server_should_start_successfully_with_valid_certificate() + { + // Server with valid cert should start normally + var serverConfig = CreateConfig(true, ValidCertPath, Password); + + using var server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server); + + // Server should be running + Assert.False(server.WhenTerminated.IsCompleted); + } + + [Fact] + public void Server_should_start_successfully_without_ssl() + { + // Server without SSL should start normally + var serverConfig = CreateConfig(false, null, null); + + using var server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server); + + // Server should be running + Assert.False(server.WhenTerminated.IsCompleted); + } + } +} diff --git a/src/core/Akka.Remote.Tests/Transport/DotNettyMutualTlsSpec.cs b/src/core/Akka.Remote.Tests/Transport/DotNettyMutualTlsSpec.cs new file mode 100644 index 00000000000..6c4dc452b0c --- /dev/null +++ b/src/core/Akka.Remote.Tests/Transport/DotNettyMutualTlsSpec.cs @@ -0,0 +1,450 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.TestKit; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Remote.Tests.Transport +{ + /// + /// Tests mutual TLS authentication enforcement in DotNetty transport. + /// When require-mutual-authentication is enabled, both client and server must + /// present valid certificates with accessible private keys. + /// + public class DotNettyMutualTlsSpec : AkkaSpec + { + private const string ValidCertPath = "Resources/akka-validcert.pfx"; + private const string ClientCertPath = "Resources/akka-client-cert.pfx"; + private const string Password = "password"; + + public DotNettyMutualTlsSpec(ITestOutputHelper output) : base(ConfigurationFactory.Empty, output) + { + } + + private static Config CreateConfig(bool enableSsl, bool requireMutualAuth, bool suppressValidation = false, string certPath = null, bool? validateCertificateHostname = null) + { + var config = ConfigurationFactory.ParseString($@" + akka {{ + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp {{ + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = {(enableSsl ? "on" : "off")} + log-transport = off + }} + }} + "); + + if (!enableSsl) + return config; + + var escapedPath = (certPath ?? ValidCertPath).Replace("\\", "\\\\"); + var hostnameValidationConfig = validateCertificateHostname.HasValue + ? $"validate-certificate-hostname = {(validateCertificateHostname.Value ? "on" : "off")}" + : ""; + + var ssl = $@" + akka.remote.dot-netty.tcp.ssl {{ + suppress-validation = {(suppressValidation ? "on" : "off")} + require-mutual-authentication = {(requireMutualAuth ? "on" : "off")} + {hostnameValidationConfig} + certificate {{ + path = ""{escapedPath}"" + password = ""{Password}"" + }} + }} + "; + return ConfigurationFactory.ParseString(ssl).WithFallback(config); + } + + [Fact] + public async Task Mutual_TLS_should_allow_connection_when_both_nodes_have_valid_certificates() + { + // Both server and client have valid certs, mutual TLS enabled + ActorSystem server = null; + ActorSystem client = null; + + try + { + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Should successfully connect and communicate + var response = await client.ActorSelection(serverEchoPath).Ask("hello", TimeSpan.FromSeconds(5)); + Assert.Equal("hello", response); + } + finally + { + if (client != null) + Shutdown(client, TimeSpan.FromSeconds(10)); + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task Mutual_TLS_disabled_should_allow_standard_TLS_connection() + { + // Server has mutual TLS disabled (standard server-only TLS) + ActorSystem server = null; + ActorSystem client = null; + + try + { + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: false, suppressValidation: true); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: false, suppressValidation: true); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Should successfully connect with standard TLS + var response = await client.ActorSelection(serverEchoPath).Ask("hello", TimeSpan.FromSeconds(5)); + Assert.Equal("hello", response); + } + finally + { + if (client != null) + Shutdown(client, TimeSpan.FromSeconds(10)); + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public void System_should_start_successfully_with_mutual_TLS_enabled() + { + // Verify that enabling mutual TLS doesn't break system startup + ActorSystem sys = null; + + try + { + var config = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true); + sys = ActorSystem.Create("TestSystem", config); + InitializeLogger(sys); + + // System should be running + Assert.False(sys.WhenTerminated.IsCompleted); + + // Remote should be initialized + var remoteAddress = RARP.For(sys).Provider.DefaultAddress; + Assert.NotNull(remoteAddress); + } + finally + { + if (sys != null) + Shutdown(sys, TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task Mutual_TLS_should_fail_when_client_has_no_certificate() + { + // Server requires mutual TLS, client has SSL enabled but no certificate configured + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server with mutual TLS required + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Client with SSL enabled but mutual TLS disabled (won't send client certificate) + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: false, suppressValidation: true); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + // Should fail to connect because server requires client certificate + // Enhanced error message "no client certificate provided" will be logged to server logs + await Assert.ThrowsAsync(async () => + { + await client.ActorSelection(serverEchoPath).Ask("hello", TimeSpan.FromSeconds(3)); + }); + } + finally + { + if (client != null) + Shutdown(client, TimeSpan.FromSeconds(10)); + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task Mutual_TLS_can_be_disabled_for_backward_compatibility() + { + // Test that setting require-mutual-authentication = false allows old behavior + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server with mutual TLS explicitly disabled + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: false, suppressValidation: true); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + // Client with SSL but potentially no valid client cert + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: false, suppressValidation: true); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Should successfully connect even with mutual TLS disabled + var response = await client.ActorSelection(serverEchoPath).Ask("hello", TimeSpan.FromSeconds(5)); + Assert.Equal("hello", response); + } + finally + { + if (client != null) + Shutdown(client, TimeSpan.FromSeconds(10)); + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task Mutual_TLS_should_fail_when_client_has_different_valid_certificate() + { + // Server and client have different valid certificates - mutual TLS should fail + // because the certificates are not trusted by each other + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server with mutual TLS using the original certificate + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: false, + certPath: ValidCertPath); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Client with mutual TLS using a different certificate + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: false, + certPath: ClientCertPath); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + // Connection should fail due to certificate mismatch + // Enhanced error message with certificate validation details will be logged to server logs + await Assert.ThrowsAsync(async () => + { + await client.ActorSelection(serverEchoPath).Ask("hello", TimeSpan.FromSeconds(3)); + }); + } + finally + { + if (client != null) + Shutdown(client, TimeSpan.FromSeconds(10)); + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + [Fact(DisplayName = "Different certificates with hostname validation disabled should connect successfully")] + public async Task Hostname_validation_disabled_should_allow_different_certificates() + { + // Per-node certificates should work when hostname validation is disabled + // Note: Using suppressValidation=true to bypass chain validation since test certs are self-signed + // This isolates the hostname validation logic we're testing + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server with one certificate, hostname validation disabled + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ValidCertPath, validateCertificateHostname: false); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Client with different certificate, hostname validation disabled + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ClientCertPath, validateCertificateHostname: false); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + // Should successfully connect because hostname validation is disabled + var response = await client.ActorSelection(serverEchoPath).Ask("hello", TimeSpan.FromSeconds(5)); + Assert.Equal("hello", response); + } + finally + { + if (client != null) + Shutdown(client, TimeSpan.FromSeconds(10)); + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + [Fact(DisplayName = "Different certificates with hostname validation enabled should fail with name mismatch")] + public async Task Hostname_validation_enabled_should_reject_different_certificates() + { + // When hostname validation is enabled, different certificates should fail with RemoteCertificateNameMismatch + // Note: Using suppressValidation=true to bypass chain validation and test hostname validation specifically + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server with one certificate, hostname validation enabled + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ValidCertPath, validateCertificateHostname: true); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Client with different certificate, hostname validation enabled + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ClientCertPath, validateCertificateHostname: true); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + // Should fail because hostname in certificate doesn't match connection target (127.0.0.1) + await Assert.ThrowsAsync(async () => + { + await client.ActorSelection(serverEchoPath).Ask("hello", TimeSpan.FromSeconds(3)); + }); + } + finally + { + if (client != null) + Shutdown(client, TimeSpan.FromSeconds(10)); + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + [Fact(DisplayName = "Same certificate should connect successfully (typical mutual TLS scenario)")] + public async Task Same_certificate_should_connect_in_mutual_tls() + { + // Typical mutual TLS: Both nodes use the same shared certificate + // Hostname validation disabled because we're using IPs/per-node certs + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server with same certificate, hostname validation disabled (typical for mutual TLS) + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ValidCertPath, validateCertificateHostname: false); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Client with same certificate, hostname validation disabled + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ValidCertPath, validateCertificateHostname: false); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + // Should successfully connect - typical mutual TLS scenario + var response = await client.ActorSelection(serverEchoPath).Ask("hello", TimeSpan.FromSeconds(5)); + Assert.Equal("hello", response); + } + finally + { + if (client != null) + Shutdown(client, TimeSpan.FromSeconds(10)); + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + [Fact(DisplayName = "Hostname validation unspecified should default to disabled (backward compatibility)")] + public async Task Hostname_validation_default_should_be_disabled() + { + // When validate-certificate-hostname is not specified, it should default to false + // Note: Using suppressValidation=true to bypass chain validation and test hostname default behavior + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server without specifying hostname validation (should default to false) + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ValidCertPath, validateCertificateHostname: null); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Client with different certificate, hostname validation unspecified (should default to false) + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ClientCertPath, validateCertificateHostname: null); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + // Should successfully connect because hostname validation defaults to disabled + var response = await client.ActorSelection(serverEchoPath).Ask("hello", TimeSpan.FromSeconds(5)); + Assert.Equal("hello", response); + } + finally + { + if (client != null) + Shutdown(client, TimeSpan.FromSeconds(10)); + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + private sealed class EchoActor : ReceiveActor + { + public EchoActor() + { + ReceiveAny(msg => Sender.Tell(msg)); + } + } + } +} diff --git a/src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs b/src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs index e75a5cb9b95..62cecfbe8b9 100644 --- a/src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs +++ b/src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs @@ -73,13 +73,9 @@ public DotNettySslSetupSpec(ITestOutputHelper output) : base(TestActorSystemSetu { } - #if !NET471 [Fact] public async Task Secure_transport_should_be_possible_between_systems_sharing_the_same_certificate() { - // skip this test due to linux/mono certificate issues - if (IsMono) return; - Setup(true); var probe = CreateTestProbe(); @@ -90,7 +86,6 @@ await AwaitAssertAsync(async () => await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); } - #endif [Fact] public async Task Secure_transport_should_NOT_be_possible_between_systems_using_SSL_and_one_not_using_it() @@ -105,6 +100,668 @@ await Assert.ThrowsAsync(async () => }); } + [Fact(DisplayName = "DotNettySslSetup with 2 parameters should configure effective DotNettyTransportSettings with defaults (RequireMutualAuth=true, ValidateHostname=false)")] + public void Two_parameter_setup_should_configure_transport_settings_with_defaults() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: true); + + var actorSystemSetup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@" +akka { + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + } +}"))) + .And(sslSetup); + + using var sys = ActorSystem.Create("test", actorSystemSetup); + + // Verify that DotNettyTransportSettings.Create uses the setup correctly + var settings = DotNettyTransportSettings.Create(sys); + + Assert.True(settings.EnableSsl); + Assert.Equal(certificate, settings.Ssl.Certificate); + Assert.True(settings.Ssl.SuppressValidation); + Assert.True(settings.Ssl.RequireMutualAuthentication); // default from 2-param constructor + Assert.False(settings.Ssl.ValidateCertificateHostname); // default from 2-param constructor + } + + [Fact(DisplayName = "DotNettySslSetup with 3 parameters should configure effective DotNettyTransportSettings with specified RequireMutualAuth and default ValidateHostname=false")] + public void Three_parameter_setup_should_configure_transport_settings() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: false); + + var actorSystemSetup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@" +akka { + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + } +}"))) + .And(sslSetup); + + using var sys = ActorSystem.Create("test", actorSystemSetup); + + // Verify that DotNettyTransportSettings.Create uses the setup correctly + var settings = DotNettyTransportSettings.Create(sys); + + Assert.True(settings.EnableSsl); + Assert.Equal(certificate, settings.Ssl.Certificate); + Assert.False(settings.Ssl.SuppressValidation); + Assert.False(settings.Ssl.RequireMutualAuthentication); // explicitly set to false + Assert.False(settings.Ssl.ValidateCertificateHostname); // default from 3-param constructor + } + + [Fact(DisplayName = "DotNettySslSetup with 4 parameters should configure effective DotNettyTransportSettings with all specified values")] + public void Four_parameter_setup_should_configure_transport_settings_with_all_values() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: true, requireMutualAuthentication: false, validateCertificateHostname: true); + + var actorSystemSetup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@" +akka { + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + } +}"))) + .And(sslSetup); + + using var sys = ActorSystem.Create("test", actorSystemSetup); + + // Verify that DotNettyTransportSettings.Create uses the setup correctly + var settings = DotNettyTransportSettings.Create(sys); + + Assert.True(settings.EnableSsl); + Assert.Equal(certificate, settings.Ssl.Certificate); + Assert.True(settings.Ssl.SuppressValidation); + Assert.False(settings.Ssl.RequireMutualAuthentication); // explicitly set to false + Assert.True(settings.Ssl.ValidateCertificateHostname); // explicitly set to true + } + + [Fact(DisplayName = "DotNettySslSetup should override HOCON certificate configuration (Bug #7917)")] + public void DotNettySslSetup_should_override_HOCON_certificate() + { + // This test exposes the bug where HOCON certificate wins over DotNettySslSetup + // when HOCON has valid certificate configuration + + // HOCON certificate + const string hoconCertPath = "Resources/akka-validcert.pfx"; + var hoconCert = new X509Certificate2(hoconCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Programmatic setup certificate (different from HOCON) + const string setupCertPath = "Resources/akka-client-cert.pfx"; + var setupCert = new X509Certificate2(setupCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + var sslSetup = new DotNettySslSetup(setupCert, suppressValidation: true, requireMutualAuthentication: false, validateCertificateHostname: true); + + var actorSystemSetup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString($@" +akka {{ + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp {{ + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + ssl {{ + certificate {{ + path = ""{hoconCertPath}"" + password = ""{Password}"" + }} + suppress-validation = false + require-mutual-authentication = true + validate-certificate-hostname = false + }} + }} +}}"))) + .And(sslSetup); + + using var sys = ActorSystem.Create("test", actorSystemSetup); + + // Verify that DotNettyTransportSettings.Create uses the setup correctly + var settings = DotNettyTransportSettings.Create(sys); + + Assert.True(settings.EnableSsl); + + // BUG: DotNettySslSetup should take precedence over HOCON, but currently HOCON wins + // because CreateOrDefault tries HOCON first, and only uses the setup as an exception fallback + Assert.Equal(setupCert.Thumbprint, settings.Ssl.Certificate.Thumbprint); // Should be setupCert, not hoconCert + Assert.True(settings.Ssl.SuppressValidation); // From DotNettySslSetup + Assert.False(settings.Ssl.RequireMutualAuthentication); // From DotNettySslSetup, not HOCON + Assert.True(settings.Ssl.ValidateCertificateHostname); // From DotNettySslSetup, not HOCON + } + + [Fact(DisplayName = "DotNettySslSetup with CustomValidator that accepts should allow connection")] + public async Task CustomValidator_that_accepts_should_allow_connection() + { + var validatorCalled = false; + + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Custom validator that accepts all certificates + CertificateValidationCallback customValidator = (cert, chain, peer, errors, log) => + { + validatorCalled = true; + Output.WriteLine($"CustomValidator called for peer: {peer}"); + return true; // Accept all certificates + }; + + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: customValidator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(ConfigurationFactory.ParseString(@" +akka { + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-custom-validator", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + await AwaitAssertAsync(async () => + { + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); + }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); + + // Verify that CustomValidator was actually called + Assert.True(validatorCalled, "CustomValidator should have been invoked during TLS handshake"); + } + + [Fact(DisplayName = "DotNettySslSetup with CustomValidator that rejects should prevent connection")] + public async Task CustomValidator_that_rejects_should_prevent_connection() + { + var validatorCalled = false; + + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Custom validator that rejects all certificates + CertificateValidationCallback customValidator = (cert, chain, peer, errors, log) => + { + validatorCalled = true; + Output.WriteLine($"CustomValidator called for peer: {peer}, rejecting certificate"); + return false; // Reject all certificates + }; + + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: customValidator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(ConfigurationFactory.ParseString(@" +akka { + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-reject-validator", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Connection should fail due to custom validator rejection - TLS handshake fails, so message never arrives + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectNoMsgAsync(TimeSpan.FromSeconds(3)); + + // Verify that CustomValidator was actually called + Assert.True(validatorCalled, "CustomValidator should have been invoked during TLS handshake"); + } + + [Fact(DisplayName = "DotNettySslSetup should pass CustomValidator to SslSettings")] + public void DotNettySslSetup_should_pass_CustomValidator_to_SslSettings() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + var customValidator = CertificateValidation.ValidateChain(); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: customValidator); + + var actorSystemSetup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@" +akka { + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + } +}"))) + .And(sslSetup); + + using var sys = ActorSystem.Create("test-custom-validator", actorSystemSetup); + + // Verify that CustomValidator is passed through to SslSettings + var settings = DotNettyTransportSettings.Create(sys); + Assert.NotNull(settings.Ssl.CustomValidator); + Assert.Same(customValidator, settings.Ssl.CustomValidator); + } + + [Fact(DisplayName = "DotNettySslSetup should take precedence when both setup and HOCON SSL are configured (and log warning)")] + public void DotNettySslSetup_should_take_precedence_when_both_configured() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // HOCON certificate (different from setup) + const string hoconCertPath = "Resources/akka-validcert.pfx"; + + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: true); + + var actorSystemSetup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString($@" +akka {{ + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp {{ + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + ssl {{ + certificate {{ + path = ""{hoconCertPath}"" + password = ""{Password}"" + }} + suppress-validation = false + }} + }} +}}"))) + .And(sslSetup); + + using var sys = ActorSystem.Create("test-precedence", actorSystemSetup); + + // Verify DotNettySslSetup takes precedence over HOCON + // (A warning will be logged to help users understand this behavior) + var settings = DotNettyTransportSettings.Create(sys); + + Assert.True(settings.EnableSsl); + Assert.Equal(certificate.Thumbprint, settings.Ssl.Certificate.Thumbprint); + Assert.True(settings.Ssl.SuppressValidation); // From DotNettySslSetup, not HOCON (which has false) + } + + [Fact(DisplayName = "CertificateValidation.PinnedCertificate should accept certificates with matching thumbprint")] + public async Task PinnedCertificate_should_accept_matching_thumbprint() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create validator that pins to this specific certificate + var validator = CertificateValidation.PinnedCertificate(certificate.Thumbprint); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: validator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(ConfigurationFactory.ParseString(@" +akka { + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-pinned-accept", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Should successfully connect because thumbprint matches + await AwaitAssertAsync(async () => + { + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); + }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); + } + + [Fact(DisplayName = "CertificateValidation.PinnedCertificate should reject certificates with non-matching thumbprint")] + public async Task PinnedCertificate_should_reject_non_matching_thumbprint() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create validator that pins to a DIFFERENT thumbprint (connection should fail) + var validator = CertificateValidation.PinnedCertificate("0000000000000000000000000000000000000000"); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: validator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(ConfigurationFactory.ParseString(@" +akka { + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-pinned-reject", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Connection should fail due to thumbprint mismatch + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectNoMsgAsync(TimeSpan.FromSeconds(3)); + } + + [Fact(DisplayName = "CertificateValidation.ValidateSubject should accept certificates with matching subject")] + public async Task ValidateSubject_should_accept_matching_subject() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create validator that accepts the certificate's actual subject + var validator = CertificateValidation.ValidateSubject(certificate.Subject); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: validator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(ConfigurationFactory.ParseString(@" +akka { + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-subject-accept", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Should successfully connect because subject matches + await AwaitAssertAsync(async () => + { + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); + }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); + } + + [Fact(DisplayName = "CertificateValidation.ValidateSubject should reject certificates with non-matching subject")] + public async Task ValidateSubject_should_reject_non_matching_subject() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create validator with a subject that won't match + var validator = CertificateValidation.ValidateSubject("CN=WrongSubject"); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: validator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(ConfigurationFactory.ParseString(@" +akka { + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-subject-reject", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Connection should fail due to subject mismatch + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectNoMsgAsync(TimeSpan.FromSeconds(3)); + } + + [Fact(DisplayName = "CertificateValidation.ValidateSubject should support wildcard patterns")] + public void ValidateSubject_should_support_wildcards() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Extract the CN from the subject (e.g., "CN=akka.net, O=Test") + // If subject is "CN=akka.net, O=Test", wildcard "CN=akka*" should match + var subject = certificate.Subject; + Output.WriteLine($"Certificate subject: {subject}"); + + // Test that wildcard pattern matching works + // Extract just the CN part for wildcard testing + var cnStart = subject.IndexOf("CN="); + if (cnStart >= 0) + { + var cnEnd = subject.IndexOf(",", cnStart); + var cn = cnEnd > cnStart ? subject.Substring(cnStart, cnEnd - cnStart) : subject.Substring(cnStart); + + // Extract the first few characters of CN for wildcard + var cnValue = cn.Substring(3); // Skip "CN=" + if (cnValue.Length > 3) + { + var wildcardPattern = "CN=" + cnValue.Substring(0, cnValue.Length - 2) + "*"; + Output.WriteLine($"Testing wildcard pattern: {wildcardPattern}"); + + var validator = CertificateValidation.ValidateSubject(wildcardPattern); + + // Invoke the validator directly to test pattern matching + var log = Akka.Event.Logging.GetLogger(Sys, "test"); + var result = validator(certificate, null, "test-peer", System.Net.Security.SslPolicyErrors.None, log); + Assert.True(result, $"Wildcard pattern '{wildcardPattern}' should match subject '{subject}'"); + } + } + } + + [Fact(DisplayName = "CertificateValidation.ValidateIssuer should accept certificates with matching issuer")] + public async Task ValidateIssuer_should_accept_matching_issuer() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create validator that accepts the certificate's actual issuer + var validator = CertificateValidation.ValidateIssuer(certificate.Issuer); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: validator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(ConfigurationFactory.ParseString(@" +akka { + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-issuer-accept", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Should successfully connect because issuer matches + await AwaitAssertAsync(async () => + { + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); + }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); + } + + [Fact(DisplayName = "CertificateValidation.ChainPlusThen should combine chain validation with custom logic")] + public async Task ChainPlusThen_should_combine_validation() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create validator that does chain validation PLUS custom check + // Note: For self-signed certificates, chain validation will fail, so we'll verify + // the custom logic is invoked by using Combine with a custom validator instead + var customCheckCalled = false; + var validator = CertificateValidation.Combine( + // Accept all for testing (since cert is self-signed) + (cert, chain, peer, errors, log) => true, + // Then custom check - just verify it's called + (cert, chain, peer, errors, log) => + { + customCheckCalled = true; + Output.WriteLine($"Custom validation called for peer: {peer}, subject: {cert?.Subject}"); + // Accept all - we're just testing that Combine works + return true; + } + ); + + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: validator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(ConfigurationFactory.ParseString(@" +akka { + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-chainplusthen", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Should successfully connect (custom validator accepts all, then custom check passes) + await AwaitAssertAsync(async () => + { + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); + }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); + + // Verify custom validation was actually called + Assert.True(customCheckCalled, "Custom validation logic should have been invoked"); + } + + [Fact(DisplayName = "CustomValidator should take precedence over validateCertificateHostname setting")] + public async Task CustomValidator_should_override_hostname_validation_setting() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create a custom validator that accepts everything + var customValidatorCalled = false; + CertificateValidationCallback customValidator = (cert, chain, peer, errors, log) => + { + customValidatorCalled = true; + Output.WriteLine($"CustomValidator called (should take precedence over hostname validation)"); + return true; // Accept all + }; + + // Configure with validateCertificateHostname=true, but customValidator should win + var sslSetup = new DotNettySslSetup( + certificate, + suppressValidation: false, + requireMutualAuthentication: true, + validateCertificateHostname: true, // This would normally fail + customValidator: customValidator // But this should take precedence + ); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(ConfigurationFactory.ParseString(@" +akka { + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-custom-precedence", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Should successfully connect because CustomValidator accepts all (overrides hostname validation) + await AwaitAssertAsync(async () => + { + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); + }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); + + // Verify custom validator was called (proving it took precedence) + Assert.True(customValidatorCalled, "CustomValidator should have been invoked, proving it takes precedence"); + } + #region helper classes / methods protected override void AfterAll() diff --git a/src/core/Akka.Remote.Tests/Transport/DotNettySslSupportSpec.cs b/src/core/Akka.Remote.Tests/Transport/DotNettySslSupportSpec.cs index 5754f57db59..a79cb03873b 100644 --- a/src/core/Akka.Remote.Tests/Transport/DotNettySslSupportSpec.cs +++ b/src/core/Akka.Remote.Tests/Transport/DotNettySslSupportSpec.cs @@ -164,9 +164,6 @@ public DotNettySslSupportSpec(ITestOutputHelper output) : base(TestConfig(ValidC [Fact] public async Task Secure_transport_should_be_possible_between_systems_sharing_the_same_certificate() { - // skip this test due to linux/mono certificate issues - if (IsMono) return; - Setup(ValidCertPath, Password); var probe = CreateTestProbe(); @@ -181,8 +178,6 @@ await AwaitAssertAsync(async () => [LocalFact(SkipLocal = "Racy in Azure AzDo CI/CD")] public async Task Secure_transport_should_be_possible_between_systems_using_thumbprint() { - // skip this test due to linux/mono certificate issues - if (IsMono) return; try { SetupThumbprint(ValidCertPath, Password); @@ -221,9 +216,6 @@ await Assert.ThrowsAsync(async () => [Fact] public async Task If_EnableSsl_configuration_is_true_but_not_valid_certificate_is_provided_than_ArgumentNullException_should_be_thrown() { - // skip this test due to linux/mono certificate issues - if (IsMono) return; - var aggregateException = await Assert.ThrowsAsync(() => { Setup(true, null, Password); return Task.CompletedTask; @@ -238,9 +230,6 @@ public async Task If_EnableSsl_configuration_is_true_but_not_valid_certificate_i [Fact] public async Task If_EnableSsl_configuration_is_true_but_not_valid_certificate_password_is_provided_than_WindowsCryptographicException_should_be_thrown() { - // skip this test due to linux/mono certificate issues - if (IsMono) return; - var aggregateException = await Assert.ThrowsAsync(() => { Setup(true, ValidCertPath, null); return Task.CompletedTask; @@ -248,9 +237,11 @@ public async Task If_EnableSsl_configuration_is_true_but_not_valid_certificate_p var realException = GetInnerMostException(aggregateException); Assert.NotNull(realException); - // TODO: this error message is not correct, but wanted to keep this assertion here in case someone else - // wants to fix it in the future. - //Assert.Equal("The specified network password is not correct.", realException.Message); + // NOTE: The error message for incorrect certificate password comes from the .NET Framework + // during X509Certificate2 construction, not from our code. The exact message is platform-dependent + // (e.g., "The specified network password is not correct" on Windows, different on Linux). + // We cannot improve this message as it's not generated by our TLS handshake code. + // Enhanced error messages are provided during TLS handshake failures (see DotNettyTlsHandshakeFailureSpec). } [Theory] diff --git a/src/core/Akka.Remote.Tests/Transport/DotNettyTlsHandshakeFailureSpec.cs b/src/core/Akka.Remote.Tests/Transport/DotNettyTlsHandshakeFailureSpec.cs new file mode 100644 index 00000000000..43abf7e29f6 --- /dev/null +++ b/src/core/Akka.Remote.Tests/Transport/DotNettyTlsHandshakeFailureSpec.cs @@ -0,0 +1,221 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.IO; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.TestKit; +using Akka.Event; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Remote.Tests.Transport +{ + public class DotNettyTlsHandshakeFailureSpec : AkkaSpec + { + private const string ValidCertPath = "Resources/akka-validcert.pfx"; + private const string Password = "password"; + private static readonly string NoKeyCertPath = Path.Combine("Resources", "handshake-no-key.cer"); + + public DotNettyTlsHandshakeFailureSpec(ITestOutputHelper output) : base(ConfigurationFactory.Empty, output) + { + } + + private static Config CreateConfig(bool enableSsl, string certPath, string certPassword, bool suppressValidation = true, int port = 0) + { + var baseConfig = ConfigurationFactory.ParseString(@"akka { + loglevel = DEBUG + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = " + port + @" + hostname = ""127.0.0.1"" + enable-ssl = " + (enableSsl ? "on" : "off") + @" + log-transport = off + } + }"); + + if (!enableSsl || string.IsNullOrEmpty(certPath)) + return baseConfig; + + var escapedPath = certPath.Replace("\\", "\\\\"); + var ssl = $@"akka.remote.dot-netty.tcp.ssl {{ + suppress-validation = {(suppressValidation ? "on" : "off")} + certificate {{ + path = ""{escapedPath}"" + password = ""{certPassword ?? string.Empty}"" + }} + }}"; + return baseConfig.WithFallback(ssl); + } + + private static void CreateCertificateWithoutPrivateKey() + { + var fullCert = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.Exportable); + var publicKeyBytes = fullCert.Export(X509ContentType.Cert); + var dir = Path.GetDirectoryName(NoKeyCertPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.WriteAllBytes(NoKeyCertPath, publicKeyBytes); + } + + + + [Fact] + public async Task Server_should_fail_at_startup_with_certificate_without_private_key() + { + CreateCertificateWithoutPrivateKey(); + + try + { + // Server with cert that has no private key should FAIL TO START + var serverConfig = CreateConfig(true, NoKeyCertPath, null, suppressValidation: true); + + // ActorSystem.Create should throw during startup due to certificate validation + var aggregateEx = Assert.Throws(() => + { + using var server = ActorSystem.Create("ServerSystem", serverConfig); + }); + + // Unwrap to find the ConfigurationException + var innerEx = aggregateEx.InnerException ?? aggregateEx; + while (innerEx is AggregateException agg && agg.InnerException != null) + innerEx = agg.InnerException; + + // Should be ConfigurationException about private key + Assert.IsType(innerEx); + Assert.Contains("private key", innerEx.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + try + { + if (File.Exists(NoKeyCertPath)) + File.Delete(NoKeyCertPath); + } + catch { /* ignore */ } + } + await Task.CompletedTask; + } + + [Fact] + public async Task Client_side_tls_handshake_failure_should_shutdown_client() + { + // Server has valid cert; client enforces validation so it should reject the self-signed server cert + ActorSystem server = null; + ActorSystem client = null; + + try + { + var serverConfig = CreateConfig(true, ValidCertPath, Password, suppressValidation: true); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + var clientConfig = CreateConfig(true, ValidCertPath, Password, suppressValidation: false); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Trigger TLS handshake failure during association + // The enhanced error message will be logged, but we can't easily assert on it + // in a multi-system test without using the TestKit's Sys + client.ActorSelection(serverEchoPath).Tell("hello"); + + // Client should shutdown due to TLS failure + await AwaitAssertAsync(async () => + { + Assert.True(client.WhenTerminated.IsCompleted); + await Task.CompletedTask; + }, TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(200)); + } + finally + { + if (client != null) + Shutdown(client, TimeSpan.FromSeconds(10)); + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + [Fact(DisplayName = "Server should NOT shutdown when invalid traffic (like HTTP) hits TLS port")] + public async Task Server_side_invalid_traffic_should_not_shutdown_server() + { + // This test addresses issue https://github.com/akkadotnet/akka.net/issues/7938 + // When invalid traffic (like HTTP requests) hits a TLS-enabled port, + // the server should reject the connection but NOT shut down + ActorSystem server = null; + + try + { + // Start server with TLS enabled on a specific port + var port = 15557; // Use a fixed port for this test + var serverConfig = CreateConfig(true, ValidCertPath, Password, suppressValidation: true, port: port); + server = ActorSystem.Create("ServerSystem", serverConfig); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + + // Ensure the server is ready by waiting for the remote transport to be bound + var serverAddress = RARP.For(server).Provider.DefaultAddress; + Assert.NotNull(serverAddress); + Assert.Equal(port, serverAddress.Port.Value); + + // Send invalid HTTP traffic to the TLS port (simulating the issue) + try + { + using var tcpClient = new TcpClient(); + await tcpClient.ConnectAsync("127.0.0.1", port); + + // Send an HTTP OPTIONS request (as described in the bug report) + var httpRequest = Encoding.UTF8.GetBytes("OPTIONS / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); + await tcpClient.GetStream().WriteAsync(httpRequest, 0, httpRequest.Length); + await tcpClient.GetStream().FlushAsync(); + + // Connection should be closed by server after rejecting invalid TLS + tcpClient.Close(); + } + catch + { + // Connection might be closed by server, that's expected + } + + // Verify the server hasn't initiated shutdown + // If it was going to shut down due to TLS failure, it would have done so immediately + await AwaitConditionAsync(() => !server.WhenTerminated.IsCompleted, + TimeSpan.FromSeconds(3), TimeSpan.FromMilliseconds(100)); + + // CRITICAL ASSERTION: Server should NOT have shut down + Assert.False(server.WhenTerminated.IsCompleted, + "Server should NOT shut down after receiving invalid HTTP traffic on TLS port"); + + // Also verify the system is still functional + var testActor = server.ActorOf(Props.Empty, "test-actor"); + Assert.NotNull(testActor); + } + finally + { + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + private sealed class EchoActor : ReceiveActor + { + public EchoActor() + { + ReceiveAny(msg => Sender.Tell(msg)); + } + } + } +} diff --git a/src/core/Akka.Remote/Configuration/Remote.conf b/src/core/Akka.Remote/Configuration/Remote.conf index 9da126e1fde..0cc7b1e2fed 100644 --- a/src/core/Akka.Remote/Configuration/Remote.conf +++ b/src/core/Akka.Remote/Configuration/Remote.conf @@ -553,6 +553,30 @@ akka { store-location = "current-user" } suppress-validation = false + + # When enabled, requires mutual TLS authentication where both client and server + # must present valid certificates with accessible private keys during the TLS handshake. + # This provides defense-in-depth security by ensuring symmetric authentication. + # + # When disabled, only server-side authentication is performed, which is + # sufficient when combined with the startup certificate validation that prevents + # servers from starting with inaccessible private keys. + # + # Set to false only if your environment cannot support client certificate authentication. + # Default: true (secure by default) + require-mutual-authentication = true + + # Enable or disable certificate hostname validation during TLS handshake. + # When true: Traditional TLS hostname validation is performed (certificate CN/SAN must match target hostname) + # When false: Only validates certificate chain against CA, ignores hostname mismatches + # + # Set to false for scenarios such as: + # - Mutual TLS with per-node certificates in P2P clusters + # - IP-based connections where certificates use DNS names + # - Service discovery with dynamic addresses + # + # Default: false (disabled for backward compatibility and mutual TLS flexibility) + validate-certificate-hostname = false } } @@ -589,4 +613,4 @@ akka { channel-executor.priority = "low" } } -} \ No newline at end of file +} diff --git a/src/core/Akka.Remote/Endpoint.cs b/src/core/Akka.Remote/Endpoint.cs index a195dd1119b..281b35d5086 100644 --- a/src/core/Akka.Remote/Endpoint.cs +++ b/src/core/Akka.Remote/Endpoint.cs @@ -209,6 +209,8 @@ protected EndpointException(SerializationInfo info, StreamingContext context) /// internal interface IAssociationProblem { } + + /// /// INTERNAL API /// @@ -1479,8 +1481,17 @@ private bool WriteSend(EndpointManager.Send send) send.Recipient, send.Recipient.Path, send.SenderOption ?? _system.DeadLetters); } - var pdu = _codec.ConstructMessage(send.Recipient.LocalAddressToUse, send.Recipient, - SerializeMessage(send.Message), send.SenderOption, send.Seq, _lastAck); + ByteString pdu; + try + { + pdu = _codec.ConstructMessage(send.Recipient.LocalAddressToUse, send.Recipient, + SerializeMessage(send.Message), send.SenderOption, send.Seq, _lastAck); + } + catch (Exception e) when (e is not SerializationException) + { + // resolves https://github.com/akkadotnet/akka.net/issues/7922 + throw new SerializationException("Serializer failed with exception", e); + } _remoteMetrics.LogPayloadBytes(send.Message, pdu.Length); @@ -1516,14 +1527,6 @@ private bool WriteSend(EndpointManager.Send send) LogPossiblyWrappedMessageType(send.Message)); return true; } - catch (ArgumentException ex) - { - _log.Error( - ex, - "Serializer threw ArgumentException for message type [{0}]. Transient association error (association remains live)", - LogPossiblyWrappedMessageType(send.Message)); - return true; - } catch (EndpointException ex) { PublishAndThrow(ex, LogLevel.ErrorLevel); diff --git a/src/core/Akka.Remote/EndpointManager.cs b/src/core/Akka.Remote/EndpointManager.cs index a9ce57581e1..3f1c1fce344 100644 --- a/src/core/Akka.Remote/EndpointManager.cs +++ b/src/core/Akka.Remote/EndpointManager.cs @@ -14,6 +14,7 @@ using Akka.Actor; using Akka.Configuration; using Akka.Dispatch; +using Akka.Remote.Transport.DotNetty; using Akka.Event; using Akka.Remote.Transport; using Akka.Util.Internal; diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs index e533b7a54e5..4d4c395c891 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs @@ -5,21 +5,112 @@ // //----------------------------------------------------------------------- +#nullable enable using System.Security.Cryptography.X509Certificates; using Akka.Actor.Setup; namespace Akka.Remote.Transport.DotNetty; +/// +/// Programmatic setup for DotNetty SSL/TLS configuration. +/// Provides a fluent API alternative to HOCON configuration. +/// public sealed class DotNettySslSetup: Setup { + /// + /// Constructor for backward compatibility - defaults to RequireMutualAuthentication = true, ValidateCertificateHostname = false + /// + /// X509 certificate used to establish SSL/TLS + /// When true, suppresses certificate chain validation (use only for development/testing) public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation) + : this(certificate, suppressValidation, requireMutualAuthentication: true, validateCertificateHostname: false, customValidator: null) + { + } + + /// + /// Constructor for backward compatibility - defaults to ValidateCertificateHostname = false + /// + /// X509 certificate used to establish SSL/TLS + /// When true, suppresses certificate chain validation (use only for development/testing) + /// When true, requires mutual TLS authentication (both client and server present certificates) + public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) + : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname: false, customValidator: null) + { + } + + /// + /// Full constructor with all SSL/TLS configuration options + /// + /// X509 certificate used to establish SSL/TLS + /// When true, suppresses certificate chain validation (use only for development/testing) + /// When true, requires mutual TLS authentication (both client and server present certificates) + /// When true, enables hostname validation (certificate CN/SAN must match target hostname) + public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) + : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname, customValidator: null) + { + } + + /// + /// Constructor with custom certificate validation callback + /// + /// X509 certificate used to establish SSL/TLS + /// When true, suppresses certificate chain validation (use only for development/testing) + /// When true, requires mutual TLS authentication (both client and server present certificates) + /// Custom certificate validation callback (overrides config-based validation when provided) + public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, CertificateValidationCallback? customValidator) + : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname: false, customValidator) + { + } + + /// + /// Full constructor with all SSL/TLS configuration options including custom validation + /// + /// X509 certificate used to establish SSL/TLS + /// When true, suppresses certificate chain validation (use only for development/testing) + /// When true, requires mutual TLS authentication (both client and server present certificates) + /// When true, enables hostname validation (certificate CN/SAN must match target hostname) + /// Custom certificate validation callback (overrides config-based validation when provided) + public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, CertificateValidationCallback? customValidator) { Certificate = certificate; SuppressValidation = suppressValidation; + RequireMutualAuthentication = requireMutualAuthentication; + ValidateCertificateHostname = validateCertificateHostname; + CustomValidator = customValidator; } - + + /// + /// X509 certificate used to establish Secure Socket Layer (SSL) between two remote endpoints. + /// public X509Certificate2 Certificate { get; } + + /// + /// Flag used to suppress certificate validation - use true only when on dev machine or for testing. + /// public bool SuppressValidation { get; } - internal SslSettings Settings => new SslSettings(Certificate, SuppressValidation); + /// + /// When true, requires mutual TLS authentication where both client and server + /// must present valid certificates with accessible private keys during the TLS handshake. + /// Provides defense-in-depth security by ensuring symmetric authentication. + /// + public bool RequireMutualAuthentication { get; } + + /// + /// When true, enables traditional TLS hostname validation (certificate CN/SAN must match target hostname). + /// When false, only validates certificate chain against CA, ignores hostname mismatches. + /// Default is false for backward compatibility and to support mutual TLS scenarios with per-node certificates, + /// IP-based connections, or dynamic service discovery. + /// + public bool ValidateCertificateHostname { get; } + + /// + /// Custom certificate validation callback for advanced validation scenarios. + /// When provided, this callback takes precedence over config-based validation. + /// Use with CertificateValidation helper factory to combine multiple validation strategies. + /// Example: CertificateValidation.Combine(ValidateChain(log), PinnedCertificate(thumbprints)) + /// + public CertificateValidationCallback? CustomValidator { get; } + + internal SslSettings Settings => new SslSettings(Certificate, SuppressValidation, RequireMutualAuthentication, ValidateCertificateHostname, CustomValidator); } diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs index c62da27b042..582ea555c1f 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs @@ -5,6 +5,7 @@ // //----------------------------------------------------------------------- +#nullable enable using System; using System.Collections.Generic; using System.Linq; @@ -70,7 +71,7 @@ public override void ExceptionCaught(IChannelHandlerContext context, Exception e protected abstract void RegisterListener(IChannel channel, IHandleEventListener listener, object msg, IPEndPoint remoteAddress); protected void Init(IChannel channel, IPEndPoint remoteSocketAddress, Address remoteAddress, object msg, - out AssociationHandle op) + out AssociationHandle? op) { var localAddress = DotNettyTransport.MapSocketToAddress((IPEndPoint)channel.LocalAddress, Transport.SchemeIdentifier, Transport.System.Name, Transport.Settings.Hostname); @@ -100,7 +101,7 @@ internal class DotNettyTransportException : RemoteTransportException /// /// The message that describes the error. /// The exception that is the cause of the current exception. - public DotNettyTransportException(string message, Exception cause = null) : base(message, cause) + public DotNettyTransportException(string message, Exception? cause = null) : base(message, cause) { } @@ -120,8 +121,8 @@ internal abstract class DotNettyTransport : Transport protected readonly TaskCompletionSource AssociationListenerPromise; protected readonly ILoggingAdapter Log; - protected volatile Address LocalAddress; - protected internal volatile IChannel ServerChannel; + protected volatile Address? LocalAddress; + protected internal volatile IChannel? ServerChannel; private readonly IEventLoopGroup _serverEventLoopGroup; private readonly IEventLoopGroup _clientEventLoopGroup; @@ -180,6 +181,13 @@ protected async Task NewServer(EndPoint listenAddress) public override async Task<(Address, TaskCompletionSource)> Listen() { + // Validate SSL certificate before starting server + // This ensures fail-fast behavior if private key is inaccessible + if (Settings.EnableSsl) + { + Settings.Ssl.ValidateCertificate(); + } + EndPoint listenAddress; if (IPAddress.TryParse(Settings.Hostname, out var ip)) listenAddress = new IPEndPoint(ip, Settings.Port); @@ -233,8 +241,8 @@ protected async Task NewServer(EndPoint listenAddress) public override Task Associate(Address remoteAddress) { - if (!ServerChannel.Open) - throw new ChannelException("Transport is not open"); + if (ServerChannel == null || !ServerChannel.Open) + throw new ChannelException("Transport is not bound or not open"); return AssociateInternal(remoteAddress); } @@ -345,11 +353,43 @@ private void SetClientPipeline(IChannel channel, Address remoteAddress) if (Settings.EnableSsl) { var certificate = Settings.Ssl.Certificate; - var host = certificate.GetNameInfo(X509NameType.DnsName, false); + // Use the remote address host for TLS validation, not the client's certificate name + var host = remoteAddress.Host; + + IChannelHandler tlsHandler; + + // Compose validator: either use custom validator or build from config settings + // This ensures a single execution path through validation logic + var validator = Settings.Ssl.CustomValidator ?? ComposeValidatorFromSettings(); + + // Create adapter bridge from our CertificateValidationCallback to RemoteCertificateValidationCallback + // The adapter extracts remote peer information from the remote address + RemoteCertificateValidationCallback validationCallback = (sender, cert, chain, errors) => + { + // Convert X509Certificate to X509Certificate2 if needed + var x509Cert = cert as X509Certificate2 ?? (cert != null ? new X509Certificate2(cert) : null); + return validator(x509Cert, chain, remoteAddress.ToString(), errors, Log); + }; - var tlsHandler = Settings.Ssl.SuppressValidation - ? new TlsHandler(stream => new SslStream(stream, true, (_, _, _, _) => true), new ClientTlsSettings(host)) - : TlsHandler.Client(host, certificate); + if (Settings.Ssl.RequireMutualAuthentication) + { + // Mutual TLS requires a certificate to be configured + if (certificate == null) + throw new InvalidOperationException("Mutual TLS authentication is enabled but no certificate is configured. Please provide a certificate via DotNettySslSetup or HOCON configuration."); + + // Provide client cert for mutual TLS + tlsHandler = new TlsHandler( + stream => new SslStream(stream, true, validationCallback, + (_, _, _, _, _) => certificate), + new ClientTlsSettings(host)); + } + else + { + // Standard TLS: Only validate server certificate, no client cert + tlsHandler = new TlsHandler( + stream => new SslStream(stream, true, validationCallback), + new ClientTlsSettings(host)); + } channel.Pipeline.AddFirst("TlsHandler", tlsHandler); } @@ -368,7 +408,48 @@ private void SetServerPipeline(IChannel channel) { if (Settings.EnableSsl) { - channel.Pipeline.AddFirst("TlsHandler", TlsHandler.Server(Settings.Ssl.Certificate)); + IChannelHandler tlsHandler; + + if (Settings.Ssl.RequireMutualAuthentication) + { + // Mutual TLS: Require client certificate authentication + // Compose validator: either use custom validator or build from config settings + // This ensures a single execution path through validation logic + var validator = Settings.Ssl.CustomValidator ?? ComposeValidatorFromSettings(); + + // Create adapter bridge from our CertificateValidationCallback to RemoteCertificateValidationCallback + // For server-side, extract the remote peer (client address) from the channel + RemoteCertificateValidationCallback validationCallback = (sender, certificate, chain, errors) => + { + // When mutual TLS is required, reject if no client certificate was provided + if (certificate == null) + { + Log.Warning("Mutual TLS required but client did not provide a certificate from {0}", + channel.RemoteAddress?.ToString() ?? "unknown"); + return false; + } + + // Extract client address from channel + var remoteAddress = channel.RemoteAddress?.ToString() ?? "unknown"; + // Convert X509Certificate to X509Certificate2 if needed + var x509Cert = certificate as X509Certificate2 ?? new X509Certificate2(certificate); + return validator(x509Cert, chain, remoteAddress, errors, Log); + }; + + tlsHandler = new TlsHandler( + stream => new SslStream( + stream, + leaveInnerStreamOpen: true, + userCertificateValidationCallback: validationCallback), + new ServerTlsSettings(Settings.Ssl.Certificate, negotiateClientCertificate: true)); + } + else + { + // Standard TLS: Server authentication only (backward compatible) + tlsHandler = TlsHandler.Server(Settings.Ssl.Certificate); + } + + channel.Pipeline.AddFirst("TlsHandler", tlsHandler); } SetInitialChannelPipeline(channel); @@ -381,6 +462,29 @@ private void SetServerPipeline(IChannel channel) } } + /// + /// Composes a certificate validation callback from the current SSL settings. + /// This creates a validator that respects SuppressValidation + /// and ValidateCertificateHostname configuration options. + /// + /// A CertificateValidationCallback composed from configuration settings. + private CertificateValidationCallback ComposeValidatorFromSettings() + { + // Build validator from configuration settings + // Note: SuppressValidation and ValidateCertificateHostname are independent settings + var suppressChain = Settings.Ssl.SuppressValidation; + var validateHostname = Settings.Ssl.ValidateCertificateHostname; + + return suppressChain switch + { + true when validateHostname => CertificateValidation.ValidateHostname(log: Log), + true => (cert, chain, peer, errors, log) => true, + false when validateHostname => CertificateValidation.Combine( + CertificateValidation.ValidateChain(log: Log), CertificateValidation.ValidateHostname(log: Log)), + _ => CertificateValidation.ValidateChain(log: Log) + }; + } + private ServerBootstrap ServerFactory() { if (InternalTransport != TransportMode.Tcp) @@ -433,14 +537,14 @@ private async Task ResolveNameAsync(DnsEndPoint address, AddressFami #region static methods - public static Address MapSocketToAddress(IPEndPoint socketAddress, string schemeIdentifier, string systemName, string hostName = null, int? publicPort = null) + public static Address? MapSocketToAddress(IPEndPoint socketAddress, string schemeIdentifier, string systemName, string? hostName = null, int? publicPort = null) { return socketAddress == null ? null : new Address(schemeIdentifier, systemName, SafeMapHostName(hostName) ?? SafeMapIPv6(socketAddress.Address), publicPort ?? socketAddress.Port); } - private static string SafeMapHostName(string hostName) + private static string? SafeMapHostName(string? hostName) { return !string.IsNullOrEmpty(hostName) && IPAddress.TryParse(hostName, out var ip) ? SafeMapIPv6(ip) : hostName; } diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs index 26135f86c0d..12dde7ce233 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs @@ -5,13 +5,17 @@ // //----------------------------------------------------------------------- +#nullable enable using System; +using System.Collections.Generic; using System.Linq; using System.Net; +using System.Net.Security; using System.Security.Cryptography.X509Certificates; using Akka.Actor; using Akka.Configuration; using Akka.Dispatch; +using Akka.Event; using Akka.Util; using DotNetty.Buffers; @@ -141,9 +145,26 @@ public static DotNettyTransportSettings Create(ActorSystem system) var config = system.Settings.Config.GetConfig("akka.remote.dot-netty.tcp"); if (config.IsNullOrEmpty()) throw ConfigurationException.NullOrEmptyConfig("akka.remote.dot-netty.tcp"); - + var setup = system.Settings.Setup.Get(); var sslSettings = setup.HasValue ? setup.Value.Settings : null; + + // Warn if both DotNettySslSetup and HOCON SSL are configured (DotNettySslSetup takes precedence) + if (sslSettings != null && config.GetBoolean("enable-ssl")) + { + var sslConfig = config.GetConfig("ssl"); + // Only warn if HOCON has explicit certificate configuration + var hasCertPath = sslConfig.HasPath("certificate.path") && !string.IsNullOrWhiteSpace(sslConfig.GetString("certificate.path")); + var hasCertThumbprint = sslConfig.HasPath("certificate.thumbprint") && !string.IsNullOrWhiteSpace(sslConfig.GetString("certificate.thumbprint")); + + if (hasCertPath || hasCertThumbprint) + { + var log = Logging.GetLogger(system, typeof(DotNettyTransportSettings)); + log.Warning("Both DotNettySslSetup and HOCON SSL configuration are present. " + + "DotNettySslSetup takes precedence and HOCON SSL settings will be ignored."); + } + } + return Create(config, sslSettings); } @@ -201,7 +222,7 @@ public static DotNettyTransportSettings Create(Config config, SslSettings? sslSe ServerSocketWorkerPoolSize: ComputeWorkerPoolSize(config.GetConfig("server-socket-worker-pool")), ClientSocketWorkerPoolSize: ComputeWorkerPoolSize(config.GetConfig("client-socket-worker-pool")), MaxFrameSize: ToNullableInt(config.GetByteSize("maximum-frame-size", null)) ?? 128000, - Ssl: enableSsl ? SslSettings.CreateOrDefault(config.GetConfig("ssl"), sslSettings) : SslSettings.Empty, + Ssl: enableSsl ? (sslSettings ?? SslSettings.Create(config.GetConfig("ssl"))) : SslSettings.Empty, DnsUseIpv6: config.GetBoolean("dns-use-ipv6"), TcpReuseAddr: ResolveTcpReuseAddrOption(config.GetString("tcp-reuse-addr", "off-for-windows")), TcpKeepAlive: config.GetBoolean("tcp-keepalive", true), @@ -264,23 +285,28 @@ public static SslSettings CreateOrDefault(Config config, SslSettings? @default = } } - private static SslSettings Create(Config config) + internal static SslSettings Create(Config config) { if (config.IsNullOrEmpty()) throw new ConfigurationException($"Failed to create {typeof(DotNettyTransportSettings)}: DotNetty SSL HOCON config was not found (default path: `akka.remote.dot-netty.tcp.ssl`)"); + var requireMutualAuth = config.GetBoolean("require-mutual-authentication", true); + var validateCertificateHostname = config.GetBoolean("validate-certificate-hostname", false); + if (config.GetBoolean("certificate.use-thumprint-over-file") || config.GetBoolean("certificate.use-thumbprint-over-file")) { - var thumbprint = config.GetString("certificate.thumbprint") + var thumbprint = config.GetString("certificate.thumbprint") ?? config.GetString("certificate.thumpbrint"); if (string.IsNullOrWhiteSpace(thumbprint)) throw new Exception("`akka.remote.dot-netty.tcp.ssl.certificate.use-thumbprint-over-file` is set to true but `akka.remote.dot-netty.tcp.ssl.certificate.thumbprint` is null or empty"); - + return new SslSettings(certificateThumbprint: thumbprint, storeName: config.GetString("certificate.store-name"), storeLocation: ParseStoreLocationName(config.GetString("certificate.store-location")), - suppressValidation: config.GetBoolean("suppress-validation")); + suppressValidation: config.GetBoolean("suppress-validation"), + requireMutualAuthentication: requireMutualAuth, + validateCertificateHostname: validateCertificateHostname); } var flagsRaw = config.GetStringList("certificate.flags", new string[] { }); @@ -290,7 +316,9 @@ private static SslSettings Create(Config config) certificatePath: config.GetString("certificate.path"), certificatePassword: config.GetString("certificate.password"), flags: flags, - suppressValidation: config.GetBoolean("suppress-validation")); + suppressValidation: config.GetBoolean("suppress-validation"), + requireMutualAuthentication: requireMutualAuth, + validateCertificateHostname: validateCertificateHostname); } @@ -330,19 +358,117 @@ private static X509KeyStorageFlags ParseKeyStorageFlag(string str) /// public readonly bool SuppressValidation; + /// + /// When true, requires mutual TLS authentication where both client and server + /// must present valid certificates with accessible private keys during the TLS handshake. + /// Provides defense-in-depth security by ensuring symmetric authentication. + /// + public readonly bool RequireMutualAuthentication; + + /// + /// When true, enables traditional TLS hostname validation (certificate CN/SAN must match target hostname). + /// When false, only validates certificate chain against CA, ignores hostname mismatches. + /// Default is false for backward compatibility and to support mutual TLS scenarios with per-node certificates, + /// IP-based connections, or dynamic service discovery. + /// + public readonly bool ValidateCertificateHostname; + + /// + /// Custom certificate validation callback (overrides config-based validation when provided) + /// + public readonly CertificateValidationCallback? CustomValidator; + private SslSettings() { Certificate = null; SuppressValidation = false; + RequireMutualAuthentication = false; + ValidateCertificateHostname = false; + CustomValidator = null; } + /// + /// Constructor for backward compatibility - defaults to RequireMutualAuthentication = true, ValidateCertificateHostname = false + /// public SslSettings(X509Certificate2 certificate, bool suppressValidation) + : this(certificate, suppressValidation, requireMutualAuthentication: true, validateCertificateHostname: false, customValidator: null) + { + } + + /// + /// Constructor for backward compatibility - defaults to ValidateCertificateHostname = false + /// + public SslSettings(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) + : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname: false, customValidator: null) + { + } + + public SslSettings(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) + : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname, customValidator: null) + { + } + + public SslSettings(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, CertificateValidationCallback? customValidator) { Certificate = certificate; SuppressValidation = suppressValidation; + RequireMutualAuthentication = requireMutualAuthentication; + ValidateCertificateHostname = validateCertificateHostname; + CustomValidator = customValidator; + } + + /// + /// Validates that the SSL certificate has an accessible private key. + /// Should be called before starting the server to ensure proper TLS configuration. + /// + /// + /// Thrown when certificate lacks private key or application cannot access it. + /// + public void ValidateCertificate() + { + if (Certificate == null) + return; // No SSL configured + + if (!Certificate.HasPrivateKey) + { + throw new ConfigurationException( + "SSL certificate does not have a private key. " + + "Ensure certificate is installed with private key permissions."); + } + + // Actually test private key access (not just presence) + // SslStream supports both RSA and ECDSA keys - check both types + try + { + using (var rsaKey = Certificate.GetRSAPrivateKey()) + using (var ecdsaKey = Certificate.GetECDsaPrivateKey()) + { + // Certificate must have either RSA or ECDSA private key accessible + if (rsaKey == null && ecdsaKey == null) + { + throw new ConfigurationException( + "Cannot access private key for SSL certificate. " + + "Certificate has private key but application lacks permissions to access it. " + + "Verify application has permissions to the certificate's private key."); + } + // Successfully accessed private key - validation passed + } + } + catch (System.Security.Cryptography.CryptographicException ex) + { + throw new ConfigurationException( + "SSL certificate private key exists but cannot be accessed. " + + "Verify application user has permissions to the private key in certificate store. " + + $"Error: {ex.Message}", ex); + } + } + + private SslSettings(string certificateThumbprint, string storeName, StoreLocation storeLocation, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) + : this(certificateThumbprint, storeName, storeLocation, suppressValidation, requireMutualAuthentication, validateCertificateHostname, customValidator: null) + { } - private SslSettings(string certificateThumbprint, string storeName, StoreLocation storeLocation, bool suppressValidation) + private SslSettings(string certificateThumbprint, string storeName, StoreLocation storeLocation, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, CertificateValidationCallback? customValidator) { using var store = new X509Store(storeName, storeLocation); store.Open(OpenFlags.ReadOnly); @@ -356,15 +482,542 @@ private SslSettings(string certificateThumbprint, string storeName, StoreLocatio Certificate = find[0]; SuppressValidation = suppressValidation; + RequireMutualAuthentication = requireMutualAuthentication; + ValidateCertificateHostname = validateCertificateHostname; + CustomValidator = customValidator; } - private SslSettings(string certificatePath, string certificatePassword, X509KeyStorageFlags flags, bool suppressValidation) + private SslSettings(string certificatePath, string certificatePassword, X509KeyStorageFlags flags, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) + : this(certificatePath, certificatePassword, flags, suppressValidation, requireMutualAuthentication, validateCertificateHostname, customValidator: null) + { + } + + private SslSettings(string certificatePath, string certificatePassword, X509KeyStorageFlags flags, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, CertificateValidationCallback? customValidator) { if (string.IsNullOrEmpty(certificatePath)) throw new ArgumentNullException(nameof(certificatePath), "Path to SSL certificate was not found (by default it can be found under `akka.remote.dot-netty.tcp.ssl.certificate.path`)"); Certificate = new X509Certificate2(certificatePath, certificatePassword, flags); SuppressValidation = suppressValidation; + RequireMutualAuthentication = requireMutualAuthentication; + ValidateCertificateHostname = validateCertificateHostname; + CustomValidator = customValidator; + } + } + + /// + /// PUBLIC API + /// + /// Custom certificate validation callback for mTLS connections. + /// Invoked during TLS handshake on both client and server sides. + /// + /// The peer certificate to validate + /// The X509 chain for validation + /// The remote address/peer identifier + /// SSL policy errors from standard validation + /// Logger for diagnostics + /// True to accept cert, false to reject + public delegate bool CertificateValidationCallback( + X509Certificate2? certificate, + X509Chain? chain, + string remotePeer, + SslPolicyErrors errors, + ILoggingAdapter log); + + /// + /// PUBLIC API + /// + /// Factory methods for common certificate validation scenarios. + /// Helpers return delegates that can be composed or used standalone. + /// Each helper creates a CertificateValidationCallback that can be passed to DotNettySslSetup. + /// + public static class CertificateValidation + { + /// + /// Validate certificate chain against system CA store. + /// Use for: CA-signed certificates in production. + /// + public static CertificateValidationCallback ValidateChain( + ILoggingAdapter? log = null) + { + return (cert, chain, peer, errors, noClosureLog) => + { + if (cert == null) + { + (log ?? noClosureLog).Error("Certificate chain validation failed for {0}: certificate is null", peer); + return false; + } + + var filteredErrors = errors & ~SslPolicyErrors.RemoteCertificateNameMismatch; + if (filteredErrors == SslPolicyErrors.None) + return true; + + var detailedError = TlsErrorMessageBuilder.BuildSslPolicyErrorMessage( + filteredErrors, cert, chain); + (log ?? noClosureLog).Error("Certificate chain validation failed for {0}:\n{1}", peer, detailedError); + return false; + }; + } + + /// + /// Validate certificate hostname (CN/SAN) matches expected hostname. + /// Use for: Per-node certificates, FQDN-based identity. + /// Applies bidirectionally on both client and server. + /// + public static CertificateValidationCallback ValidateHostname( + string? expectedHostname = null, + ILoggingAdapter? log = null) + { + return (cert, chain, peer, errors, nonClosureLog) => + { + if (cert == null) + { + (log ?? nonClosureLog).Error( + "Hostname validation failed for {0}: certificate is null", + peer); + return false; + } + + var hostname = expectedHostname ?? peer; + + if ((errors & SslPolicyErrors.RemoteCertificateNameMismatch) == 0) return true; + var cn = cert.GetNameInfo(X509NameType.DnsName, false); + (log ?? nonClosureLog).Error( + "Hostname validation failed for {0}: expected '{1}', certificate CN is '{2}'", + peer, hostname, cn); + return false; + + }; + } + + /// + /// Pin certificate by thumbprint. Only accept certs matching allowed list. + /// Use for: High-security scenarios, known peer certificates. + /// Best combined with: Certificate revocation checking. + /// + public static CertificateValidationCallback PinnedCertificate( + params string[] allowedThumbprints) + { + if (allowedThumbprints == null || allowedThumbprints.Length == 0) + throw new ArgumentException("At least one thumbprint required"); + + // Normalize thumbprints to uppercase for case-insensitive comparison. + // This is SAFE because thumbprints are hexadecimal representations of SHA hashes. + // "2A8B4C" and "2a8b4c" represent the same binary value - just different display conventions. + // Different tools display thumbprints differently (Windows=uppercase, OpenSSL=lowercase), + // so case-insensitive comparison improves usability without compromising security. + // Also filter out any null/empty thumbprints to prevent security issues. + var normalizedThumbprints = new HashSet( + allowedThumbprints + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.ToUpperInvariant())); + + if (normalizedThumbprints.Count == 0) + throw new ArgumentException("At least one valid (non-empty) thumbprint required"); + + return (cert, chain, peer, errors, log) => + { + if (cert == null) + { + log.Error("Certificate pinning failed for {0}: certificate is null", peer); + return false; + } + + var thumbprint = cert.Thumbprint?.ToUpperInvariant(); + + if (string.IsNullOrEmpty(thumbprint)) + { + log.Error("Certificate pinning failed for {0}: certificate has no thumbprint", peer); + return false; + } + + if (!normalizedThumbprints.Contains(thumbprint!)) + { + log.Error("Certificate pinning failed for {0}: thumbprint '{1}' not in allowed list", + peer, thumbprint); + return false; + } + + return true; + }; + } + + /// + /// Validate certificate subject DN matches expected pattern. + /// Use for: Organizational CA, issuer-based identity verification. + /// Supports wildcards: "CN=Akka-Node-*" matches "CN=Akka-Node-001" + /// + public static CertificateValidationCallback ValidateSubject( + string expectedSubjectPattern, + ILoggingAdapter? log = null) + { + if (string.IsNullOrWhiteSpace(expectedSubjectPattern)) + throw new ArgumentException("Subject pattern required"); + + return (cert, chain, peer, errors, log_) => + { + if (cert == null) + { + (log ?? log_).Error( + "Subject validation failed for {0}: certificate is null", + peer); + return false; + } + + var cert509 = cert as X509Certificate2; + var subject = cert509?.Subject; + + if (string.IsNullOrEmpty(subject)) + { + (log ?? log_).Error( + "Subject validation failed for {0}: certificate has no subject", + peer); + return false; + } + + if (!SubjectMatchesPattern(subject, expectedSubjectPattern)) + { + (log ?? log_).Error( + "Subject validation failed for {0}: '{1}' does not match pattern '{2}'", + peer, subject, expectedSubjectPattern); + return false; + } + + return true; + }; + } + + /// + /// Validate certificate issuer matches expected DN pattern. + /// Use for: Verifying certificate came from trusted CA. + /// + public static CertificateValidationCallback ValidateIssuer( + string expectedIssuerPattern, + ILoggingAdapter? log = null) + { + if (string.IsNullOrWhiteSpace(expectedIssuerPattern)) + throw new ArgumentException("Issuer pattern required"); + + return (cert, chain, peer, errors, log_) => + { + if (cert == null) + { + (log ?? log_).Error( + "Issuer validation failed for {0}: certificate is null", + peer); + return false; + } + + var cert509 = cert as X509Certificate2; + var issuer = cert509?.Issuer; + + if (string.IsNullOrEmpty(issuer)) + { + (log ?? log_).Error( + "Issuer validation failed for {0}: certificate has no issuer", + peer); + return false; + } + + if (!SubjectMatchesPattern(issuer, expectedIssuerPattern)) + { + (log ?? log_).Error( + "Issuer validation failed for {0}: '{1}' does not match pattern '{2}'", + peer, issuer, expectedIssuerPattern); + return false; + } + + return true; + }; + } + + /// + /// Compose multiple validation callbacks into a single callback. + /// All validators must pass for certificate to be accepted. + /// Use for: Combining multiple validation strategies. + /// + public static CertificateValidationCallback Combine( + params CertificateValidationCallback[] validators) + { + if (validators == null || validators.Length == 0) + throw new ArgumentException("At least one validator required"); + + return (cert, chain, peer, errors, log) => + { + foreach (var validator in validators!) + { + if (!validator(cert, chain, peer, errors, log)) + return false; + } + return true; + }; + } + + /// + /// Chain validator with optional custom validation. + /// Validates certificate chain, then calls optional custom logic. + /// + public static CertificateValidationCallback ChainPlusThen( + Func customCheck, + ILoggingAdapter? log = null) + { + if (customCheck == null) + throw new ArgumentException("Custom check function required"); + + return (cert, chain, peer, errors, log_) => + { + // First validate chain + var chainValidator = ValidateChain(log ?? log_); + if (!chainValidator(cert, chain, peer, errors, log_)) + return false; + + // Then custom check + if (!customCheck(cert, chain, peer)) + { + (log ?? log_).Error("Custom certificate validation failed for {0}", peer); + return false; + } + + return true; + }; + } + + private static bool SubjectMatchesPattern(string? subject, string pattern) + { + // Simple wildcard matching: CN=Akka-Node-* matches CN=Akka-Node-001 + if (string.IsNullOrEmpty(subject)) + return false; + + var regex = "^" + System.Text.RegularExpressions.Regex.Escape(pattern) + .Replace("\\*", ".*") + "$"; + return System.Text.RegularExpressions.Regex.IsMatch(subject, regex); + } + } + + /// + /// INTERNAL API + /// + /// Helper class for building human-readable error messages for TLS/SSL certificate validation failures. + /// Provides detailed diagnostics and actionable suggestions for common certificate issues. + /// + internal static class TlsErrorMessageBuilder + { + /// + /// Builds a detailed error message for SSL policy errors encountered during TLS handshake. + /// + /// The SSL policy errors from certificate validation callback + /// The certificate that failed validation (may be null) + /// The X509 chain used for validation (may be null) + /// A human-readable error message with diagnostics and suggestions + public static string BuildSslPolicyErrorMessage( + System.Net.Security.SslPolicyErrors errors, + X509Certificate2? certificate, + X509Chain? chain) + { + var message = new System.Text.StringBuilder(); + message.AppendLine("TLS/SSL certificate validation failed:"); + + // Interpret SslPolicyErrors flags + if (errors != System.Net.Security.SslPolicyErrors.None) + { + if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNotAvailable) != 0) + { + message.AppendLine(" - Remote certificate not available"); + message.AppendLine(" Suggestion: Ensure the remote endpoint provides a valid TLS certificate"); + } + + if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0) + { + message.AppendLine(" - Remote certificate name mismatch"); + message.AppendLine(" Suggestion: Verify certificate CN/SAN matches the target hostname"); + if (certificate != null) + { + var cn = certificate.GetNameInfo(X509NameType.DnsName, false); + message.AppendLine($" Certificate CN: {cn}"); + } + } + + if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateChainErrors) != 0) + { + message.AppendLine(" - Certificate chain validation errors"); + + if (chain != null && chain.ChainStatus.Length > 0) + { + var chainStatusMsg = BuildX509ChainStatusMessage(chain.ChainStatus); + message.Append(chainStatusMsg); + } + else + { + message.AppendLine(" Suggestion: Certificate chain cannot be validated. " + + "Install required intermediate CA certificates."); + } + } + } + + // Add certificate details if available + if (certificate != null) + { + message.AppendLine($"\nCertificate Details:"); + message.AppendLine($" Subject: {certificate.Subject}"); + message.AppendLine($" Issuer: {certificate.Issuer}"); + message.AppendLine($" Thumbprint: {certificate.Thumbprint}"); + message.AppendLine($" Valid From: {certificate.NotBefore:yyyy-MM-dd HH:mm:ss}"); + message.AppendLine($" Valid To: {certificate.NotAfter:yyyy-MM-dd HH:mm:ss}"); + message.AppendLine($" Has Private Key: {certificate.HasPrivateKey}"); + } + + return message.ToString().TrimEnd(); + } + + /// + /// Builds a detailed message explaining X509 chain status errors. + /// + /// Array of chain status from X509Chain validation + /// Human-readable explanation of chain errors with suggestions + public static string BuildX509ChainStatusMessage(X509ChainStatus[] chainStatus) + { + var message = new System.Text.StringBuilder(); + + foreach (var status in chainStatus) + { + // Skip "NoError" status + if (status.Status == X509ChainStatusFlags.NoError) + continue; + + message.AppendLine($" - {status.Status}: {status.StatusInformation}"); + + // Add specific suggestions based on chain status + var suggestion = GetChainStatusSuggestion(status.Status); + if (!string.IsNullOrEmpty(suggestion)) + { + message.AppendLine($" Suggestion: {suggestion}"); + } + } + + return message.ToString(); + } + + /// + /// Maps X509ChainStatusFlags to actionable suggestions for fixing the issue. + /// + private static string GetChainStatusSuggestion(X509ChainStatusFlags status) + { + return status switch + { + X509ChainStatusFlags.NotTimeValid => + "Certificate has expired or is not yet valid. Check system clock and certificate validity period.", + + X509ChainStatusFlags.NotTimeNested => + "Certificate validity period does not nest correctly within the chain.", + + X509ChainStatusFlags.Revoked => + "Certificate has been revoked. Contact certificate issuer.", + + X509ChainStatusFlags.NotSignatureValid => + "Certificate signature is invalid. Certificate may be corrupted.", + + X509ChainStatusFlags.NotValidForUsage => + "Certificate is not valid for the intended usage. Check Extended Key Usage (EKU) extensions.", + + X509ChainStatusFlags.UntrustedRoot => + "Certificate chain terminates in an untrusted root. Install root CA certificate in Trusted Root Certification Authorities store.", + + X509ChainStatusFlags.RevocationStatusUnknown => + "Revocation status cannot be determined. Check network connectivity to CRL/OCSP endpoints.", + + X509ChainStatusFlags.Cyclic => + "Certificate chain contains a cycle. Certificate configuration is invalid.", + + X509ChainStatusFlags.InvalidExtension => + "Certificate contains an invalid extension.", + + X509ChainStatusFlags.InvalidPolicyConstraints => + "Certificate policy constraints are invalid.", + + X509ChainStatusFlags.InvalidBasicConstraints => + "Basic constraints are invalid. CA certificate may be missing CA:TRUE constraint.", + + X509ChainStatusFlags.InvalidNameConstraints => + "Name constraints in certificate are invalid.", + + X509ChainStatusFlags.HasNotSupportedNameConstraint => + "Certificate contains name constraints that are not supported.", + + X509ChainStatusFlags.HasNotDefinedNameConstraint => + "Certificate has undefined name constraints.", + + X509ChainStatusFlags.HasNotPermittedNameConstraint => + "Certificate name violates name constraints.", + + X509ChainStatusFlags.HasExcludedNameConstraint => + "Certificate name is explicitly excluded by name constraints.", + + X509ChainStatusFlags.PartialChain => + "Certificate chain is incomplete. Install all intermediate CA certificates from your certificate provider.", + + X509ChainStatusFlags.CtlNotTimeValid => + "Certificate Trust List (CTL) is not time-valid.", + + X509ChainStatusFlags.CtlNotSignatureValid => + "Certificate Trust List (CTL) signature is invalid.", + + X509ChainStatusFlags.CtlNotValidForUsage => + "Certificate Trust List (CTL) is not valid for this usage.", + + X509ChainStatusFlags.OfflineRevocation => + "Revocation checking is offline. Enable network access or disable revocation checking for testing.", + + X509ChainStatusFlags.NoIssuanceChainPolicy => + "Certificate does not have a valid issuance policy.", + + X509ChainStatusFlags.ExplicitDistrust => + "Certificate is explicitly distrusted. Remove from Distrusted Certificates store if this is incorrect.", + + X509ChainStatusFlags.HasNotSupportedCriticalExtension => + "Certificate has an unsupported critical extension.", + + X509ChainStatusFlags.HasWeakSignature => + "Certificate uses a weak signature algorithm (e.g., SHA1). Use SHA256 or stronger.", + + _ => string.Empty + }; + } + + /// + /// Builds an error message for TLS handshake exceptions. + /// Attempts to extract meaningful information from CryptographicException and AuthenticationException. + /// + public static string BuildTlsHandshakeErrorMessage(Exception exception, bool isClient) + { + var role = isClient ? "Client" : "Server"; + var message = new System.Text.StringBuilder(); + + message.AppendLine($"TLS handshake failed ({role} side):"); + message.AppendLine($" Error: {exception.Message}"); + + // Provide role-specific suggestions + if (isClient) + { + message.AppendLine("\nClient-side TLS troubleshooting:"); + message.AppendLine(" - Verify server certificate is trusted (install root CA if using self-signed)"); + message.AppendLine(" - Check certificate hostname matches connection target"); + message.AppendLine(" - For mutual TLS, ensure client certificate is configured, accessible, and trusted by server"); + message.AppendLine(" - Server and client certificates must have compatible trust chains"); + } + else + { + message.AppendLine("\nServer-side TLS troubleshooting:"); + message.AppendLine(" - Verify server certificate has accessible private key"); + message.AppendLine(" - For mutual TLS, check if client is providing a certificate"); + message.AppendLine(" - Review certificate validation requirements (suppress-validation for testing)"); + } + + if (exception.InnerException != null) + { + message.AppendLine($"\nInner Exception: {exception.InnerException.Message}"); + } + + return message.ToString().TrimEnd(); } } } diff --git a/src/core/Akka.Remote/Transport/DotNetty/TcpTransport.cs b/src/core/Akka.Remote/Transport/DotNetty/TcpTransport.cs index 3cc853b4970..11568123f58 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/TcpTransport.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/TcpTransport.cs @@ -15,11 +15,26 @@ using Akka.Event; using DotNetty.Buffers; using DotNetty.Common.Utilities; +using DotNetty.Handlers.Tls; using DotNetty.Transport.Channels; using Google.Protobuf; namespace Akka.Remote.Transport.DotNetty { + internal sealed class TlsHandshakeFailureReason : CoordinatedShutdown.Reason + { + public TlsHandshakeFailureReason(string message) + { + Message = message; + } + + public string Message { get; } + + public override int ExitCode => 79; + + public override string ToString() => Message; + } + internal abstract class TcpHandlers : CommonHandlers { private IHandleEventListener _listener; @@ -63,6 +78,42 @@ public override void ChannelRead(IChannelHandlerContext context, object message) ReferenceCountUtil.SafeRelease(message); } + public override void UserEventTriggered(IChannelHandlerContext context, object evt) + { + if (evt is TlsHandshakeCompletionEvent { IsSuccessful: false } tlsEvent) + { + var ex = tlsEvent.Exception ?? new Exception("TLS handshake failed."); + + // Determine if this is client or server side based on handler type + var isClient = this is TcpClientHandler; + var detailedError = TlsErrorMessageBuilder.BuildTlsHandshakeErrorMessage(ex, isClient); + + Log.Error(ex, "TLS handshake failed on channel [{0}->{1}](Id={2})\n{3}", + context.Channel.LocalAddress, context.Channel.RemoteAddress, + context.Channel.Id, detailedError); + + // Only shutdown the ActorSystem if this is a client-side failure + // Server-side failures (incoming connections) should just reject the connection + if (isClient) + { + // Client-side: We initiated the connection and TLS failed - this is critical + var cs = CoordinatedShutdown.Get(Transport.System); + cs.Run(new TlsHandshakeFailureReason($"TLS handshake failed on outbound connection to [{context.Channel.RemoteAddress}]")); + } + else + { + // Server-side: Someone connected to us with invalid TLS - just reject them + Log.Warning("Rejected incoming connection from [{0}] due to TLS handshake failure. This is likely invalid or malicious traffic.", + context.Channel.RemoteAddress); + } + + context.CloseAsync(); + return; // don't pass to next handlers + } + + base.UserEventTriggered(context, evt); + } + /// /// TBD /// @@ -86,6 +137,19 @@ public override void ExceptionCaught(IChannelHandlerContext context, Exception e NotifyListener(new Disassociated(DisassociateInfo.Shutdown)); } + // Enhanced TLS exception handling + else if (exception is System.Security.Authentication.AuthenticationException + or System.Security.Cryptography.CryptographicException) + { + // Determine if this is client or server side based on handler type + var isClient = this is TcpClientHandler; + var detailedError = TlsErrorMessageBuilder.BuildTlsHandshakeErrorMessage(exception, isClient); + + Log.Error(exception, "TLS exception on channel [{0}->{1}](Id={2})\n{3}", + context.Channel.LocalAddress, context.Channel.RemoteAddress, context.Channel.Id, detailedError); + + NotifyListener(new Disassociated(DisassociateInfo.Unknown)); + } else { base.ExceptionCaught(context, exception); @@ -133,9 +197,11 @@ void InitInbound(IChannel channel, IPEndPoint socketAddress, object msg) internal sealed class TcpClientHandler : TcpHandlers { private readonly TaskCompletionSource _statusPromise = new(); + private readonly TaskCompletionSource _tlsHandshakePromise = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly Address _remoteAddress; public Task StatusFuture => _statusPromise.Task; + public Task TlsHandshakeTask => _tlsHandshakePromise.Task; public TcpClientHandler(DotNettyTransport transport, ILoggingAdapter log, Address remoteAddress) : base(transport, log) @@ -150,6 +216,24 @@ public override void ChannelActive(IChannelHandlerContext context) } + public override void UserEventTriggered(IChannelHandlerContext context, object evt) + { + if (evt is TlsHandshakeCompletionEvent tlsEvent) + { + if (tlsEvent.IsSuccessful) + { + _tlsHandshakePromise.TrySetResult(true); + } + else + { + var ex = tlsEvent.Exception ?? new Exception("TLS handshake failed."); + _tlsHandshakePromise.TrySetException(ex); + } + } + + base.UserEventTriggered(context, evt); + } + private void InitOutbound(IChannel channel, IPEndPoint socketAddress, object msg) { Init(channel, socketAddress, _remoteAddress, msg, out var handle); @@ -207,7 +291,23 @@ protected override async Task AssociateInternal(Address remot socketAddress = await MapEndpointAsync(socketAddress).ConfigureAwait(false); var associate = await clientBootstrap.ConnectAsync(socketAddress).ConfigureAwait(false); var handler = (TcpClientHandler)associate.Pipeline.Last(); - return await handler.StatusFuture.ConfigureAwait(false); + // Wait for channel activation (socket connect) + var handle = await handler.StatusFuture.ConfigureAwait(false); + + if (!Settings.EnableSsl) + return handle; + + // If SSL is enabled, ensure the TLS handshake has completed successfully + try + { + await handler.TlsHandshakeTask.ConfigureAwait(false); + } + catch (Exception ex) + { + throw new InvalidAssociationException($"TLS handshake failed for {remoteAddress}: {ex.Message}", ex); + } + + return handle; } catch (ConnectException c) { diff --git a/src/core/Akka.Streams.Tests/Dsl/StreamRefsSpec.cs b/src/core/Akka.Streams.Tests/Dsl/StreamRefsSpec.cs index 4907c4f7c81..9fdd34050a3 100644 --- a/src/core/Akka.Streams.Tests/Dsl/StreamRefsSpec.cs +++ b/src/core/Akka.Streams.Tests/Dsl/StreamRefsSpec.cs @@ -478,10 +478,86 @@ public void SinkRef_must_not_allow_materializing_multiple_times() var p1 = this.SourceProbe().To(sinkRef.Sink).Run(Materializer); p1.EnsureSubscription(); var req = p1.ExpectRequest(); - + var p2 = this.SourceProbe().To(sinkRef.Sink).Run(Materializer); p2.EnsureSubscription(); // will be cancelled immediately, since it's 2nd p2.ExpectCancellation(); } + + [Fact] + public async Task SourceRef_Source_property_should_be_idempotent_issue_7895() + { + // Reproduction test for issue #7895: https://github.com/akkadotnet/akka.net/issues/7895 + // The .Source property creates a new SourceRefStageImpl on every access, + // which is not idempotent behavior and can cause intermittent subscription timeouts + + // Create a SourceRef + var sourceRef = await Source.From(new[] { 1, 2, 3 }) + .ToMaterialized(StreamRefs.SourceRef(), Keep.Right) + .Run(Materializer); + + // Access .Source property twice (simulates multiple accesses) + // This could happen via debugger inspection, logging, serialization, etc. + var source1 = sourceRef.Source; + var source2 = sourceRef.Source; + + // BUG: They're NOT the same object (non-idempotent behavior) + // Each property access creates a new Source with a new SourceRefStageImpl + // When fixed, this assertion should PASS with ReferenceEquals(source1, source2) == true + ReferenceEquals(source1, source2).Should().BeTrue( + "Source property should be idempotent and return the same instance"); + } + + [Fact] + public async Task SourceRef_multiple_materializations_cause_timeout_issue_7895() + { + // Reproduction test for issue #7895: https://github.com/akkadotnet/akka.net/issues/7895 + // This test demonstrates the race condition from multiple .Source property accesses + // Multiple .Source property accesses create racing SourceRefStageImpl instances + + // Create a SourceRef with short timeout + var sourceRef = await Source.From(Enumerable.Range(1, 100)) + .ToMaterialized(StreamRefs.SourceRef(), Keep.Right) + .WithAttributes(StreamRefAttributes.CreateSubscriptionTimeout(TimeSpan.FromSeconds(3))) + .Run(Materializer); + + // Access .Source twice - creates TWO SourceRefStageImpl instances + var source1 = sourceRef.Source; + var source2 = sourceRef.Source; + + // Materialize both - they race for the same SinkRef handshake + var task1 = source1.RunWith(Sink.Seq(), Materializer); + var task2 = source2.RunWith(Sink.Seq(), Materializer); + + // Wait for both with timeout protection + var allTasks = Task.WhenAll( + task1.ContinueWith(t => t), + task2.ContinueWith(t => t) + ); + + try + { + await allTasks; + } + catch + { + // Expected: at least one should fail + } + + // Check results - at least one should have failed/timed out + var results = new[] { task1, task2 }; + var completedCount = results.Count(t => t.Status == TaskStatus.RanToCompletion); + var faultedCount = results.Count(t => t.Status == TaskStatus.Faulted); + + // Due to race condition: sometimes both fail, sometimes one succeeds + (completedCount + faultedCount).Should().Be(2, "Both tasks should have completed or faulted"); + + // At least one should have issues due to duplicate stage instances + if (faultedCount > 0) + { + var failedTask = results.First(t => t.Status == TaskStatus.Faulted); + failedTask.Exception.InnerException.Should().BeOfType(); + } + } } } diff --git a/src/core/Akka.Streams.Tests/Implementation/ChannelSourceSpec.cs b/src/core/Akka.Streams.Tests/Implementation/ChannelSourceSpec.cs index f0535eef1d6..2961df9b577 100644 --- a/src/core/Akka.Streams.Tests/Implementation/ChannelSourceSpec.cs +++ b/src/core/Akka.Streams.Tests/Implementation/ChannelSourceSpec.cs @@ -6,6 +6,9 @@ //----------------------------------------------------------------------- using System; +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Linq; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; @@ -92,5 +95,52 @@ public async Task ChannelSource_must_read_incoming_events() probe.ExpectNext(4); probe.ExpectNext(5); } + + /// + /// Reproduces GitHub issue #7940: NullReferenceException when completing + /// a ChannelReader while the stream is waiting for data. + /// + [Fact(DisplayName = "ChannelSource should not throw NRE when completing channel while waiting for data")] + public async Task ChannelSource_should_not_throw_NRE_when_completing_channel_while_waiting_for_data() + { + // This test reproduces the race condition from #7940 + // Run multiple iterations to increase chance of hitting the race + for (var iteration = 0; iteration < 20; iteration++) + { + var channel = Channel.CreateUnbounded(); + var processed = new ConcurrentBag(); + + // Exactly matches the repro from the issue - using ImmutableArray.Create and Sink.Ignore + var streamTask = ChannelSource.FromReader(channel.Reader) + .Select(ImmutableArray.Create) + .Select(s => + { + foreach (var item in s) processed.Add(item); + return Done.Instance; + }) + .ToMaterialized(Sink.Ignore(), Keep.Right) + .Run(_materializer); + + // Write some items + var testInput = Enumerable.Range(1, 5).Select(i => i.ToString()).ToList(); + foreach (var item in testInput) + await channel.Writer.WriteAsync(item); + + // Wait 1 second for stream to process items and then wait for more data + // This is the key to reproducing the race - the stream needs to be + // waiting in WaitToReadAsync when we complete the writer (channel is empty) + await Task.Delay(1000); + + // Complete the channel - this can cause NRE if there's a race + // between OnReaderComplete and the async continuation of WaitToReadAsync + channel.Writer.Complete(); + + // Stream should complete cleanly without exceptions + await streamTask; + + // Verify all items were processed + processed.Count.Should().Be(5, $"iteration {iteration} failed"); + } + } } } diff --git a/src/core/Akka.Streams.Tests/Issue7794Spec.cs b/src/core/Akka.Streams.Tests/Issue7794Spec.cs new file mode 100644 index 00000000000..689aad8dbf9 --- /dev/null +++ b/src/core/Akka.Streams.Tests/Issue7794Spec.cs @@ -0,0 +1,55 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System.Threading.Channels; +using System.Threading.Tasks; +using Akka.Streams.Dsl; +using Akka.TestKit; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Streams.Tests.Implementation; + +public class Issue7794Spec: AkkaSpec +{ + private ActorMaterializer Materializer { get; } + + public Issue7794Spec(ITestOutputHelper helper) : base(helper) + { + Materializer = Sys.Materializer(); + } + + [Fact(DisplayName = "ChannelSource should not throw NRE when Channel completes")] + public async Task Issue_7794_ChannelSource_NRE() + { + var channel = Channel.CreateBounded>(new BoundedChannelOptions(capacity: 100) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = true, + AllowSynchronousContinuations = false + }); + + var streamRes = ChannelSource.FromReader(channel.Reader) + .Select(e => e) + .RunWith(Sink.Ignore>(), Materializer); + + _ = Task.Run(async () => + { + await Task.Delay(100); + channel.Writer.Complete(); + }); + + await streamRes; + } + + private class Message + { + public TKey Key { get; set; } + public TValue Value { get; set; } + } +} \ No newline at end of file diff --git a/src/core/Akka.Streams/Implementation/ChannelSources.cs b/src/core/Akka.Streams/Implementation/ChannelSources.cs index 7097bef3516..5871561c388 100644 --- a/src/core/Akka.Streams/Implementation/ChannelSources.cs +++ b/src/core/Akka.Streams/Implementation/ChannelSources.cs @@ -6,6 +6,7 @@ //----------------------------------------------------------------------- using System; +using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using Akka.Streams.Stage; @@ -21,6 +22,10 @@ sealed class ChannelSourceLogic : OutGraphStageLogic private readonly Action _onReaderComplete; private readonly Action> _onReadReady; + // Flag to prevent race condition between OnReaderComplete and OnValueRead + // when channel completion and WaitToReadAsync fire simultaneously (issue #7940) + private int _completing; + public ChannelSourceLogic(SourceShape source, Outlet outlet, ChannelReader reader) : base(source) { @@ -44,20 +49,41 @@ public ChannelSourceLogic(SourceShape source, Outlet outlet, private void OnReaderComplete(Exception reason) { + // Use atomic compare-exchange to ensure only one completion path runs + // This prevents race with OnValueRead when both fire simultaneously + if (Interlocked.CompareExchange(ref _completing, 1, 0) != 0) + return; // Already completing from another path + if (reason is null) CompleteStage(); else FailStage(reason); } - private void OnValueReadFailure(Exception reason) => FailStage(reason); + private void OnValueReadFailure(Exception reason) + { + // Use atomic compare-exchange to ensure only one completion path runs + if (Interlocked.CompareExchange(ref _completing, 1, 0) != 0) + return; // Already completing from another path + + FailStage(reason); + } private void OnValueRead(bool dataAvailable) { if (dataAvailable && _reader.TryRead(out var element)) + { Push(_outlet, element); + } else + { + // Use atomic compare-exchange to ensure only one completion path runs + // This prevents race with OnReaderComplete when both fire simultaneously + if (Interlocked.CompareExchange(ref _completing, 1, 0) != 0) + return; // Already completing from another path + CompleteStage(); + } } public override void OnPull() @@ -73,9 +99,17 @@ public override void OnPull() { var dataAvailable = continuation.GetAwaiter().GetResult(); if (dataAvailable && _reader.TryRead(out element)) + { Push(_outlet, element); + } else + { + // Use atomic compare-exchange to ensure only one completion path runs + if (Interlocked.CompareExchange(ref _completing, 1, 0) != 0) + return; // Already completing from another path + CompleteStage(); + } } else continuation.AsTask().ContinueWith(_onReadReady); diff --git a/src/core/Akka.Streams/Implementation/StreamRef/SinkRefImpl.cs b/src/core/Akka.Streams/Implementation/StreamRef/SinkRefImpl.cs index 47bd5ae26fe..483e82d1fe7 100644 --- a/src/core/Akka.Streams/Implementation/StreamRef/SinkRefImpl.cs +++ b/src/core/Akka.Streams/Implementation/StreamRef/SinkRefImpl.cs @@ -46,11 +46,19 @@ protected SinkRefImpl(IActorRef initialPartnerRef) [InternalApi] internal sealed class SinkRefImpl : SinkRefImpl, ISinkRef { - public SinkRefImpl(IActorRef initialPartnerRef) : base(initialPartnerRef) { } + private readonly Lazy> _sink; + + public SinkRefImpl(IActorRef initialPartnerRef) : base(initialPartnerRef) + { + _sink = new Lazy>(() => + Dsl.Sink.FromGraph(new SinkRefStageImpl(InitialPartnerRef)) + .MapMaterializedValue(_ => NotUsed.Instance)); + } + public override Type EventType => typeof(T); - public override ISurrogate ToSurrogate(ActorSystem system) => SerializationTools.ToSurrogate(this); + public Sink Sink => _sink.Value; - public Sink Sink => Dsl.Sink.FromGraph(new SinkRefStageImpl(InitialPartnerRef)).MapMaterializedValue(_ => NotUsed.Instance); + public override ISurrogate ToSurrogate(ActorSystem system) => SerializationTools.ToSurrogate(this); } /// diff --git a/src/core/Akka.Streams/Implementation/StreamRef/SourceRefImpl.cs b/src/core/Akka.Streams/Implementation/StreamRef/SourceRefImpl.cs index e9bef29f744..8964129a6cd 100644 --- a/src/core/Akka.Streams/Implementation/StreamRef/SourceRefImpl.cs +++ b/src/core/Akka.Streams/Implementation/StreamRef/SourceRefImpl.cs @@ -48,13 +48,20 @@ protected SourceRefImpl(IActorRef initialPartnerRef) /// /// INTERNAL API: Implementation class, not intended to be touched directly by end-users. /// - [InternalApi] + [InternalApi] internal sealed class SourceRefImpl : SourceRefImpl, ISourceRef { - public SourceRefImpl(IActorRef initialPartnerRef) : base(initialPartnerRef) { } + private readonly Lazy> _source; + + public SourceRefImpl(IActorRef initialPartnerRef) : base(initialPartnerRef) + { + _source = new Lazy>(() => + Dsl.Source.FromGraph(new SourceRefStageImpl(InitialPartnerRef)) + .MapMaterializedValue(_ => NotUsed.Instance)); + } + public override Type EventType => typeof(T); - public Source Source => - Dsl.Source.FromGraph(new SourceRefStageImpl(InitialPartnerRef)).MapMaterializedValue(_ => NotUsed.Instance); + public Source Source => _source.Value; public override ISurrogate ToSurrogate(ActorSystem system) => SerializationTools.ToSurrogate(this); } diff --git a/src/core/Akka.Streams/Stage/GraphStage.cs b/src/core/Akka.Streams/Stage/GraphStage.cs index c14c75eba20..f273301628b 100644 --- a/src/core/Akka.Streams/Stage/GraphStage.cs +++ b/src/core/Akka.Streams/Stage/GraphStage.cs @@ -885,11 +885,18 @@ public ConcurrentAsyncCallback(Action handler, GraphStageLogic ownedStage) _ownedStage = ownedStage; _wrappedHandler = obj => { - if (obj is T e) - handler1(e); - else - throw new ArgumentException( - $"Expected {nameof(obj)} to be of type {typeof(T)}, but was {obj.GetType()}"); + switch (obj) + { + // Always assume that T can be null and the handler will handle null values + case null: + handler1(default); + break; + case T e: + handler1(e); + break; + default: + throw new ArgumentException($"Expected {nameof(obj)} to be of type {typeof(T)}, but was {obj.GetType()}"); + } }; } diff --git a/src/core/Akka.TestKit.Tests/TestActorRefTests/ParallelTestActorDeadlockSpec.cs b/src/core/Akka.TestKit.Tests/TestActorRefTests/ParallelTestActorDeadlockSpec.cs new file mode 100644 index 00000000000..3f4b33bf657 --- /dev/null +++ b/src/core/Akka.TestKit.Tests/TestActorRefTests/ParallelTestActorDeadlockSpec.cs @@ -0,0 +1,93 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Akka.Actor; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.TestKit.Tests.TestActorRefTests +{ + public class ParallelTestActorDeadlockSpec + { + private readonly ITestOutputHelper _output; + + public ParallelTestActorDeadlockSpec(ITestOutputHelper output) + { + _output = output; + } + + // This test reproduces the deadlock that occurs in Akka.Hosting.TestKit + // when multiple TestKits start up in parallel and actors try to interact + // with TestActor during initialization. + // + // Related issues: + // - https://github.com/akkadotnet/akka.net/issues/7770 + // - https://github.com/akkadotnet/Akka.Hosting/pull/643 + [Fact(Timeout = 20000)] + public async Task Parallel_TestKit_startup_should_not_deadlock() + { + var concurrentTests = 40; // High parallelism to trigger the issue + + var tasks = Enumerable.Range(0, concurrentTests) + .Select(_ => Task.Run(RunOneTestKit)) + .ToArray(); + + await Task.WhenAll(tasks); + + async Task RunOneTestKit() + { + await Task.Run(async () => + { + var id = Guid.NewGuid().ToString("N").Substring(0, 8); + try + { + _output.WriteLine($"[{id}] Creating TestKit..."); + // Create TestKit synchronously like a normal test would + using var testKit = new Akka.TestKit.Xunit2.TestKit($"test-{id}", output: _output); + _output.WriteLine($"[{id}] TestKit created"); + + // Simulate what happens in Akka.Hosting - actor creation during startup + // that tries to interact with TestActor + _output.WriteLine($"[{id}] Creating PingerActor..."); + var actor = testKit.Sys.ActorOf(Props.Create(() => new PingerActor(testKit.TestActor))); + _output.WriteLine($"[{id}] PingerActor created"); + + // Expect the "ping" message from PingerActor's PreStart + await testKit.ExpectMsgAsync("ping", TimeSpan.FromSeconds(2)); + _output.WriteLine($"[{id}] Received ping from PingerActor"); + + // Now verify the TestKit is working normally + _output.WriteLine($"[{id}] Sending test message..."); + testKit.TestActor.Tell("test-message"); + await testKit.ExpectMsgAsync("test-message", TimeSpan.FromSeconds(2)); + _output.WriteLine($"[{id}] Test completed successfully"); + } + catch (Exception ex) + { + _output.WriteLine($"[{id}] Failed: {ex.Message}"); + throw; + } + }); + } + } + + private class PingerActor : ActorBase + { + private readonly IActorRef _testActor; + + public PingerActor(IActorRef testActor) + { + _testActor = testActor; + } + + protected override bool Receive(object message) => false; + + protected override void PreStart() + { + // This simulates what StartupPinger does in Akka.Hosting + // Sending a message to TestActor during actor initialization + _testActor.Tell("ping"); + } + } + } +} \ No newline at end of file diff --git a/src/core/Akka.TestKit/EventFilter/Internal/EventFilterBase.cs b/src/core/Akka.TestKit/EventFilter/Internal/EventFilterBase.cs index 80ff0790281..69c9d9c9fd3 100644 --- a/src/core/Akka.TestKit/EventFilter/Internal/EventFilterBase.cs +++ b/src/core/Akka.TestKit/EventFilter/Internal/EventFilterBase.cs @@ -18,9 +18,9 @@ namespace Akka.TestKit.Internal /// TBD public delegate void EventMatched(EventFilterBase eventFilter, LogEvent logEvent); - /// Internal! + /// Internal! /// Facilities for selectively filtering out expected events from logging so - /// that you can keep your test run’s console output clean and do not miss real + /// that you can keep your test run's console output clean and do not miss real /// error messages. /// Note! Part of internal API. Breaking changes may occur without notice. Use at own risk. /// @@ -86,8 +86,26 @@ protected virtual void OnEventMatched(LogEvent logEvent) /// TBD protected bool InternalDoMatch(string src, object msg) { - var msgstr = msg == null ? "null" : msg.ToString(); - return _sourceMatcher.IsMatch(src) && _messageMatcher.IsMatch(msgstr); + // Check source matcher first (fast path) + if (!_sourceMatcher.IsMatch(src)) + return false; + + // For semantic logging support, try matching against both the formatted message + // and the unformatted template pattern + if (msg is LogMessage logMessage) + { + // Try matching against the template pattern first (e.g., "User {UserId} logged in") + if (_messageMatcher.IsMatch(logMessage.Format)) + return true; + + // Fall back to matching the formatted message (e.g., "User 12345 logged in") + var formattedMsg = logMessage.ToString() ?? "null"; + return _messageMatcher.IsMatch(formattedMsg); + } + + // Non-semantic logging or legacy messages + var msgstr = msg == null ? "null" : msg.ToString() ?? "null"; + return _messageMatcher.IsMatch(msgstr); } /// diff --git a/src/core/Akka.TestKit/TestKitBase.cs b/src/core/Akka.TestKit/TestKitBase.cs index 2fb8dd7847b..a30e4a58eae 100644 --- a/src/core/Akka.TestKit/TestKitBase.cs +++ b/src/core/Akka.TestKit/TestKitBase.cs @@ -116,6 +116,7 @@ protected TestKitBase(ITestKitAssertions assertions, ActorSystem system, ActorSy { _assertions = assertions ?? throw new ArgumentNullException(nameof(assertions), "The supplied assertions must not be null."); + // ReSharper disable once VirtualMemberCallInConstructor InitializeTest(system, config, actorSystemName, testActorName); } @@ -170,10 +171,11 @@ protected virtual void InitializeTest(ActorSystem system, ActorSystemSetup confi if (string.IsNullOrEmpty(testActorName)) testActorName = "testActor" + _testActorId.IncrementAndGet(); - var testActor = CreateTestActor(system, testActorName); + var testActor = CreateInitialTestActor(system, testActorName); - // Wait for the testactor to start - WaitUntilTestActorIsReady(testActor, _testState.TestKitSettings); + // For async initialization, don't wait in constructor to avoid deadlock + // The TestActor property getter will ensure it's ready when first accessed + _testState.TestActor = testActor; if (this is not INoImplicitSender) { @@ -187,45 +189,6 @@ protected virtual void InitializeTest(ActorSystem system, ActorSystemSetup confi } SynchronizationContext.SetSynchronizationContext( new ActorCellKeepingSynchronizationContext(InternalCurrentActorCellKeeper.Current)); - - _testState.TestActor = testActor; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - // Do not convert this method to async, it is being called inside the constructor. - private static void WaitUntilTestActorIsReady(IActorRef testActor, TestKitSettings settings) - { - var deadline = settings.TestKitStartupTimeout; - var stopwatch = Stopwatch.StartNew(); - var ready = false; - - try - { - // TestActor should start almost instantly (microseconds). - // Use SpinWait which will spin for ~10-20 microseconds then yield. - var spinWait = new SpinWait(); - - while (stopwatch.Elapsed < deadline) - { - ready = testActor is not IRepointableRef repRef || repRef.IsStarted; - if (ready) break; - - // SpinWait automatically handles the progression: - // - First ~10 iterations: tight spin loop (microseconds) - // - Next iterations: Thread.Yield() - // - Later: Thread.Sleep(0) - // - Finally: Thread.Sleep(1) - // This is optimal for both fast startup and system under load - spinWait.SpinOnce(); - } - } - finally - { - stopwatch.Stop(); - } - - if (!ready) - throw new Exception("Timeout waiting for test actor to be ready"); } /// @@ -710,10 +673,31 @@ public IActorRef CreateTestActor(string name) return CreateTestActor(_testState.System, name); } + private IActorRef CreateInitialTestActor(ActorSystem system, string name) + { + // Fix both serialization and deadlock issues: + // 1. Use isSystemService=true to skip serialization checks + // 2. Use isAsync=false to create LocalActorRef synchronously (avoids RepointableActorRef deadlock) + var testActorProps = Props.Create(() => new InternalTestActor(_testState.Queue)) + .WithDispatcher("akka.test.test-actor.dispatcher"); + + var systemImpl = system.AsInstanceOf(); + // Use the new AttachChildWithAsync method to create TestActor synchronously + var testActor = systemImpl.Provider.SystemGuardian.Cell.AttachChildWithAsync( + testActorProps, + isSystemService: true, // Skip serialization checks + isAsync: false, // Create synchronously to avoid deadlock + name: name); + + return testActor; + } + private IActorRef CreateTestActor(ActorSystem system, string name) { var testActorProps = Props.Create(() => new InternalTestActor(_testState.Queue)) .WithDispatcher("akka.test.test-actor.dispatcher"); + + // For additional test actors, always use the standard SystemActorOf var testActor = system.AsInstanceOf().SystemActorOf(testActorProps, name); return testActor; } diff --git a/src/core/Akka.TestKit/TestScheduler.cs b/src/core/Akka.TestKit/TestScheduler.cs index d19f6d29be7..4959223a231 100644 --- a/src/core/Akka.TestKit/TestScheduler.cs +++ b/src/core/Akka.TestKit/TestScheduler.cs @@ -82,7 +82,7 @@ public void AdvanceTo(DateTimeOffset when) Advance(when.Subtract(_now)); } - private void InternalSchedule(TimeSpan? initialDelay, TimeSpan delay, ICanTell receiver, object message, Action action, + private void InternalSchedule(TimeSpan? initialDelay, TimeSpan delay, ICanTell receiver, object message, Action action, IActorRef sender, ICancelable cancelable, int deliveryCount = 0) { var scheduledTime = _now.Add(initialDelay ?? delay).UtcTicks; diff --git a/src/core/Akka.Tests/Configuration/ConfigurationSpec.cs b/src/core/Akka.Tests/Configuration/ConfigurationSpec.cs index 6241c7dfb41..6c204fcfa30 100644 --- a/src/core/Akka.Tests/Configuration/ConfigurationSpec.cs +++ b/src/core/Akka.Tests/Configuration/ConfigurationSpec.cs @@ -50,7 +50,7 @@ public void The_default_configuration_file_contain_all_configuration_properties( settings.LogDeadLetters.ShouldBe(10); settings.LogDeadLettersDuringShutdown.ShouldBeFalse(); settings.LogDeadLettersSuspendDuration.ShouldBe(TimeSpan.FromMinutes(5)); - settings.LogFormatter.Should().BeOfType(); + settings.LogFormatter.Should().BeOfType(); settings.ProviderClass.ShouldBe(typeof (LocalActorRefProvider).FullName); settings.SupervisorStrategyClass.ShouldBe(typeof (DefaultSupervisorStrategy).FullName); diff --git a/src/core/Akka.Tests/Loggers/LogFilterEvaluatorSpecs.cs b/src/core/Akka.Tests/Loggers/LogFilterEvaluatorSpecs.cs index 1ce5487109d..c75bdb88cdb 100644 --- a/src/core/Akka.Tests/Loggers/LogFilterEvaluatorSpecs.cs +++ b/src/core/Akka.Tests/Loggers/LogFilterEvaluatorSpecs.cs @@ -227,4 +227,199 @@ public void ShouldFilterByLogMessageContains(LogEvent e, bool expected) Assert.Equal(expected, keepMessage); } } + + /// + /// Tests that log filtering works correctly with semantic logging templates + /// + public class SemanticLoggingFilterCases + { + private static ILoggingAdapter CreateAdapter() + { + var system = ActorSystem.Create("test-system"); + return Logging.GetLogger(system, "TestLogger"); + } + + [Fact] + public void ShouldFilterSemanticLogByFormattedMessageContent() + { + // Arrange: filter should exclude messages containing "12345" + var ruleBuilder = new LogFilterBuilder().ExcludeMessageContaining("12345"); + var evaluator = ruleBuilder.Build().CreateEvaluator(); + + var adapter = CreateAdapter(); + + // Act: log with semantic template - the formatted value contains "12345" + adapter.Info("User {UserId} logged in", 12345); + + // Get the LogEvent that was created + var logEvent = new Info( + null, + "TestLogger", + typeof(SemanticLoggingFilterCases), + new DefaultLogMessage( + SemanticLogMessageFormatter.Instance, + "User {UserId} logged in", + 12345)); + + // Assert: should be filtered out because formatted message contains "12345" + var keepMessage = evaluator.ShouldTryKeepMessage(logEvent, out _); + Assert.False(keepMessage); + } + + [Fact] + public void ShouldKeepSemanticLogWhenFormattedMessageDoesNotMatchFilter() + { + // Arrange: filter excludes messages containing "admin" + var ruleBuilder = new LogFilterBuilder().ExcludeMessageContaining("admin"); + var evaluator = ruleBuilder.Build().CreateEvaluator(); + + // Act: log message where neither template nor formatted value contains "admin" + var logEvent = new Info( + null, + "TestLogger", + typeof(SemanticLoggingFilterCases), + new DefaultLogMessage( + SemanticLogMessageFormatter.Instance, + "User {UserId} logged in from {IpAddress}", + 123, "192.168.1.1")); + + // Assert: should NOT be filtered (kept) + var keepMessage = evaluator.ShouldTryKeepMessage(logEvent, out _); + Assert.True(keepMessage); + } + + [Fact] + public void ShouldFilterSemanticLogByPropertyValue() + { + // Arrange: filter excludes messages containing "CRITICAL" + var ruleBuilder = new LogFilterBuilder().ExcludeMessageContaining("CRITICAL"); + var evaluator = ruleBuilder.Build().CreateEvaluator(); + + // Act: the property value contains "CRITICAL" + var logEvent = new Warning( + null, + "TestLogger", + typeof(SemanticLoggingFilterCases), + new DefaultLogMessage( + SemanticLogMessageFormatter.Instance, + "Alert level {AlertLevel} triggered", + "CRITICAL")); + + // Assert: should be filtered because formatted message is "Alert level CRITICAL triggered" + var keepMessage = evaluator.ShouldTryKeepMessage(logEvent, out _); + Assert.False(keepMessage); + } + + [Fact] + public void ShouldFilterSemanticLogWithMultipleProperties() + { + // Arrange: filter excludes messages containing "ERROR" + var ruleBuilder = new LogFilterBuilder().ExcludeMessageContaining("ERROR"); + var evaluator = ruleBuilder.Build().CreateEvaluator(); + + // Act: multiple properties, one contains "ERROR" + var logEvent = new Error( + null, + "TestLogger", + typeof(SemanticLoggingFilterCases), + new DefaultLogMessage( + SemanticLogMessageFormatter.Instance, + "Status: {Status}, Code: {ErrorCode}, User: {UserId}", + "ERROR", 500, 789)); + + // Assert: should be filtered + var keepMessage = evaluator.ShouldTryKeepMessage(logEvent, out _); + Assert.False(keepMessage); + } + + [Fact] + public void ShouldHandlePositionalTemplatesWithFiltering() + { + // Arrange: filter excludes "timeout" + var ruleBuilder = new LogFilterBuilder().ExcludeMessageContaining("timeout"); + var evaluator = ruleBuilder.Build().CreateEvaluator(); + + // Act: positional template (backward compatibility) + var logEvent = new Warning( + null, + "TestLogger", + typeof(SemanticLoggingFilterCases), + new DefaultLogMessage( + SemanticLogMessageFormatter.Instance, + "Operation {0} failed with {1}", + "database query", "timeout")); + + // Assert: should be filtered because formatted contains "timeout" + var keepMessage = evaluator.ShouldTryKeepMessage(logEvent, out _); + Assert.False(keepMessage); + } + + [Fact] + public void ShouldFilterBySourceWithSemanticLogging() + { + // Arrange: filter excludes source starting with "Akka.Tests" + var ruleBuilder = new LogFilterBuilder().ExcludeSourceStartingWith("Akka.Tests"); + var evaluator = ruleBuilder.Build().CreateEvaluator(); + + // Act: semantic log from filtered source + var logEvent = new Info( + null, + "Akka.Tests.MyTest", + typeof(SemanticLoggingFilterCases), + new DefaultLogMessage( + SemanticLogMessageFormatter.Instance, + "Test user {UserId} created", + 999)); + + // Assert: should be filtered by source (message content irrelevant) + var keepMessage = evaluator.ShouldTryKeepMessage(logEvent, out _); + Assert.False(keepMessage); + } + + [Fact] + public void ShouldKeepSemanticLogWhenSourceAndMessagePass() + { + // Arrange: filter excludes "ERROR" in message and "Akka.Tests" in source + var ruleBuilder = new LogFilterBuilder() + .ExcludeMessageContaining("ERROR") + .ExcludeSourceContaining("Akka.Tests"); + var evaluator = ruleBuilder.Build().CreateEvaluator(); + + // Act: semantic log that doesn't match either filter + var logEvent = new Info( + null, + "Akka.Cluster.Gossip", + typeof(SemanticLoggingFilterCases), + new DefaultLogMessage( + SemanticLogMessageFormatter.Instance, + "Node {NodeAddress} joined cluster", + "akka.tcp://system@localhost:8080")); + + // Assert: should NOT be filtered (kept) + var keepMessage = evaluator.ShouldTryKeepMessage(logEvent, out _); + Assert.True(keepMessage); + } + + [Fact] + public void ShouldFilterComplexSemanticLogWithFormatSpecifiers() + { + // Arrange: filter excludes messages containing "1,234.56" (formatted number) + var ruleBuilder = new LogFilterBuilder().ExcludeMessageContaining("1,234.56"); + var evaluator = ruleBuilder.Build().CreateEvaluator(); + + // Act: semantic template with format specifier + var logEvent = new Info( + null, + "TestLogger", + typeof(SemanticLoggingFilterCases), + new DefaultLogMessage( + SemanticLogMessageFormatter.Instance, + "Amount {Amount:N2} processed", + 1234.56m)); + + // Assert: should be filtered because formatted output contains "1,234.56" + var keepMessage = evaluator.ShouldTryKeepMessage(logEvent, out _); + Assert.False(keepMessage); + } + } } diff --git a/src/core/Akka.Tests/Loggers/SemanticLoggingSpecs.cs b/src/core/Akka.Tests/Loggers/SemanticLoggingSpecs.cs new file mode 100644 index 00000000000..a7f2b4e19a8 --- /dev/null +++ b/src/core/Akka.Tests/Loggers/SemanticLoggingSpecs.cs @@ -0,0 +1,495 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2025 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using Akka.Configuration; +using Akka.Event; +using Akka.TestKit; +using FluentAssertions; +using Xunit; + +namespace Akka.Tests.Loggers +{ + public class SemanticLoggingSpecs : AkkaSpec + { + public SemanticLoggingSpecs() : base(ConfigurationFactory.ParseString(@" + akka { + loglevel = INFO + stdout-loglevel = INFO + } + ")) + { + } + [Fact(DisplayName = "MessageTemplateParser should parse positional templates correctly")] + public void MessageTemplateParser_should_parse_positional_templates() + { + var template = "Value is {0} and status {1}"; + var propertyNames = MessageTemplateParser.GetPropertyNames(template); + + propertyNames.Should().NotBeNull(); + propertyNames.Should().HaveCount(2); + propertyNames[0].Should().Be("0"); + propertyNames[1].Should().Be("1"); + } + + [Fact(DisplayName = "MessageTemplateParser should parse named templates correctly")] + public void MessageTemplateParser_should_parse_named_templates() + { + var template = "User {UserId} logged in from {IpAddress}"; + var propertyNames = MessageTemplateParser.GetPropertyNames(template); + + propertyNames.Should().NotBeNull(); + propertyNames.Should().HaveCount(2); + propertyNames[0].Should().Be("UserId"); + propertyNames[1].Should().Be("IpAddress"); + } + + [Fact(DisplayName = "MessageTemplateParser should handle escaped braces")] + public void MessageTemplateParser_should_handle_escaped_braces() + { + var template = "Use {{braces}} for {Value}"; + var propertyNames = MessageTemplateParser.GetPropertyNames(template); + + propertyNames.Should().NotBeNull(); + propertyNames.Should().HaveCount(1); + propertyNames[0].Should().Be("Value"); + } + + [Fact(DisplayName = "MessageTemplateParser should handle format specifiers")] + public void MessageTemplateParser_should_handle_format_specifiers() + { + var template = "Value is {Amount:N2} dollars"; + var propertyNames = MessageTemplateParser.GetPropertyNames(template); + + propertyNames.Should().NotBeNull(); + propertyNames.Should().HaveCount(1); + propertyNames[0].Should().Be("Amount"); + } + + [Fact(DisplayName = "MessageTemplateParser should handle alignment specifiers")] + public void MessageTemplateParser_should_handle_alignment_specifiers() + { + var template = "Name: {Name,10} Age: {Age,-5}"; + var propertyNames = MessageTemplateParser.GetPropertyNames(template); + + propertyNames.Should().NotBeNull(); + propertyNames.Should().HaveCount(2); + propertyNames[0].Should().Be("Name"); + propertyNames[1].Should().Be("Age"); + } + + [Fact(DisplayName = "MessageTemplateParser should return empty list for no placeholders")] + public void MessageTemplateParser_should_return_empty_for_no_placeholders() + { + var template = "This is a plain message"; + var propertyNames = MessageTemplateParser.GetPropertyNames(template); + + propertyNames.Should().NotBeNull(); + propertyNames.Should().BeEmpty(); + } + + [Fact(DisplayName = "MessageTemplateParser should handle malformed templates gracefully")] + public void MessageTemplateParser_should_handle_malformed_templates() + { + var template = "Value is {0 and {1} without closing"; + var propertyNames = MessageTemplateParser.GetPropertyNames(template); + + // Should not throw, parses "0 and {1" from the first {..} pair + propertyNames.Should().NotBeNull(); + propertyNames.Should().HaveCount(1); + propertyNames[0].Should().Be("0 and {1"); + } + + [Fact(DisplayName = "MessageTemplateParser should cache parsed templates")] + public void MessageTemplateParser_should_cache_parsed_templates() + { + var template = "User {UserId} logged in"; + + // First call - cache miss + var result1 = MessageTemplateParser.GetPropertyNames(template); + + // Second call - should hit cache (same reference) + var result2 = MessageTemplateParser.GetPropertyNames(template); + + result1.Should().BeSameAs(result2, "cached results should return the same instance"); + } + + [Fact(DisplayName = "LogMessage should extract property names correctly")] + public void LogMessage_should_extract_property_names() + { + var formatter = DefaultLogMessageFormatter.Instance; + var logMessage = new DefaultLogMessage(formatter, "User {UserId} logged in", 123); + + var propertyNames = logMessage.PropertyNames; + + propertyNames.Should().HaveCount(1); + propertyNames[0].Should().Be("UserId"); + } + + [Fact(DisplayName = "LogMessage should create property dictionary correctly")] + public void LogMessage_should_create_property_dictionary() + { + var formatter = DefaultLogMessageFormatter.Instance; + var logMessage = new DefaultLogMessage(formatter, "User {UserId} from {IpAddress}", 123, "192.168.1.1"); + + var properties = logMessage.GetProperties(); + + properties.Should().HaveCount(2); + properties["UserId"].Should().Be(123); + properties["IpAddress"].Should().Be("192.168.1.1"); + } + + [Fact(DisplayName = "LogMessage should handle mismatched property counts")] + public void LogMessage_should_handle_mismatched_property_counts() + { + var formatter = DefaultLogMessageFormatter.Instance; + + // More values than properties + var logMessage1 = new DefaultLogMessage(formatter, "User {UserId}", 123, "extra"); + var properties1 = logMessage1.GetProperties(); + properties1.Should().HaveCount(1); + properties1["UserId"].Should().Be(123); + + // More properties than values + var logMessage2 = new DefaultLogMessage(formatter, "User {UserId} from {IpAddress}"); + var properties2 = logMessage2.GetProperties(); + properties2.Should().BeEmpty(); + } + + [Fact(DisplayName = "LogMessage property dictionary should be cached")] + public void LogMessage_property_dictionary_should_be_cached() + { + var formatter = DefaultLogMessageFormatter.Instance; + var logMessage = new DefaultLogMessage(formatter, "User {UserId}", 123); + + var properties1 = logMessage.GetProperties(); + var properties2 = logMessage.GetProperties(); + + properties1.Should().BeSameAs(properties2, "cached properties should return the same instance"); + } + + [Fact(DisplayName = "SemanticLogMessageFormatter should format positional templates")] + public void SemanticLogMessageFormatter_should_format_positional_templates() + { + var formatter = SemanticLogMessageFormatter.Instance; + var result = formatter.Format("Value is {0} and status {1}", 42, "OK"); + + result.Should().Be("Value is 42 and status OK"); + } + + [Fact(DisplayName = "SemanticLogMessageFormatter should format named templates")] + public void SemanticLogMessageFormatter_should_format_named_templates() + { + var formatter = SemanticLogMessageFormatter.Instance; + var result = formatter.Format("User {UserId} logged in from {IpAddress}", 123, "192.168.1.1"); + + result.Should().Be("User 123 logged in from 192.168.1.1"); + } + + [Fact(DisplayName = "SemanticLogMessageFormatter should handle format specifiers")] + public void SemanticLogMessageFormatter_should_handle_format_specifiers() + { + var formatter = SemanticLogMessageFormatter.Instance; + var result = formatter.Format("Amount: {Amount:N2}", 1234.5); + + // Handle culture differences - just check it contains the number with decimals + result.Should().MatchRegex(@"Amount: \d[,\d]*\.50"); + } + + [Fact(DisplayName = "SemanticLogMessageFormatter should handle missing arguments")] + public void SemanticLogMessageFormatter_should_handle_missing_arguments() + { + var formatter = SemanticLogMessageFormatter.Instance; + var result = formatter.Format("User {UserId} from {IpAddress}", 123); + + result.Should().Contain("123"); + result.Should().Contain("{IpAddress}"); // Missing arg stays as placeholder + } + + [Fact(DisplayName = "SemanticLogMessageFormatter should handle null values")] + public void SemanticLogMessageFormatter_should_handle_null_values() + { + var formatter = SemanticLogMessageFormatter.Instance; + var result = formatter.Format("Value is {Value}", (object)null); + + result.Should().Be("Value is null"); + } + + [Fact(DisplayName = "LogEventExtensions.TryGetProperties should work with LogMessage")] + public void LogEventExtensions_TryGetProperties_should_work_with_LogMessage() + { + var formatter = SemanticLogMessageFormatter.Instance; + var logMessage = new DefaultLogMessage(formatter, "User {UserId}", 123); + var logEvent = new Info(null, "TestSource", typeof(SemanticLoggingSpecs), logMessage); + + var result = logEvent.TryGetProperties(out var properties); + + result.Should().BeTrue(); + properties.Should().NotBeNull(); + properties["UserId"].Should().Be(123); + } + + [Fact(DisplayName = "LogEventExtensions.TryGetProperties should return false for string messages")] + public void LogEventExtensions_TryGetProperties_should_return_false_for_strings() + { + var logEvent = new Info(null, "TestSource", typeof(SemanticLoggingSpecs), "Plain string message"); + + var result = logEvent.TryGetProperties(out var properties); + + result.Should().BeFalse(); + properties.Should().BeNull(); + } + + [Fact(DisplayName = "LogEventExtensions.GetPropertyNames should work with LogMessage")] + public void LogEventExtensions_GetPropertyNames_should_work() + { + var formatter = SemanticLogMessageFormatter.Instance; + var logMessage = new DefaultLogMessage(formatter, "User {UserId} from {IpAddress}", 123, "192.168.1.1"); + var logEvent = new Info(null, "TestSource", typeof(SemanticLoggingSpecs), logMessage); + + var propertyNames = logEvent.GetPropertyNames(); + + propertyNames.Should().HaveCount(2); + propertyNames.Should().Contain("UserId"); + propertyNames.Should().Contain("IpAddress"); + } + + [Fact(DisplayName = "LogEventExtensions.GetTemplate should extract format string")] + public void LogEventExtensions_GetTemplate_should_work() + { + var formatter = SemanticLogMessageFormatter.Instance; + var logMessage = new DefaultLogMessage(formatter, "User {UserId}", 123); + var logEvent = new Info(null, "TestSource", typeof(SemanticLoggingSpecs), logMessage); + + var template = logEvent.GetTemplate(); + + template.Should().Be("User {UserId}"); + } + + [Fact(DisplayName = "LogEventExtensions.GetParameters should extract values")] + public void LogEventExtensions_GetParameters_should_work() + { + var formatter = SemanticLogMessageFormatter.Instance; + var logMessage = new DefaultLogMessage(formatter, "User {UserId} from {IpAddress}", 123, "192.168.1.1"); + var logEvent = new Info(null, "TestSource", typeof(SemanticLoggingSpecs), logMessage); + + var parameters = logEvent.GetParameters().ToArray(); + + parameters.Should().HaveCount(2); + parameters[0].Should().Be(123); + parameters[1].Should().Be("192.168.1.1"); + } + + [Fact(DisplayName = "LruCache should evict oldest entries when full")] + public void LruCache_should_evict_oldest_entries() + { + var cache = new LruCache(3); + + cache.Add(1, "one"); + cache.Add(2, "two"); + cache.Add(3, "three"); + cache.Add(4, "four"); // Should evict 1 + + cache.TryGet(1, out _).Should().BeFalse("1 should have been evicted"); + cache.TryGet(2, out var val2).Should().BeTrue(); + val2.Should().Be("two"); + } + + [Fact(DisplayName = "LruCache should promote accessed entries")] + public void LruCache_should_promote_accessed_entries() + { + var cache = new LruCache(3); + + cache.Add(1, "one"); + cache.Add(2, "two"); + cache.Add(3, "three"); + + // Access 1, promoting it to front + cache.TryGet(1, out _).Should().BeTrue(); + + // Add 4, should evict 2 (oldest) + cache.Add(4, "four"); + + cache.TryGet(1, out _).Should().BeTrue("1 was promoted"); + cache.TryGet(2, out _).Should().BeFalse("2 should have been evicted"); + cache.TryGet(3, out _).Should().BeTrue(); + cache.TryGet(4, out _).Should().BeTrue(); + } + + [Fact(DisplayName = "End-to-end semantic logging should work")] + public void End_to_end_semantic_logging_should_work() + { + var formatter = SemanticLogMessageFormatter.Instance; + var logMessage = new DefaultLogMessage(formatter, "User {UserId} performed action {Action}", 123, "Login"); + var logEvent = new Info(null, "TestSource", typeof(SemanticLoggingSpecs), logMessage); + + // Property extraction + logEvent.TryGetProperties(out var properties).Should().BeTrue(); + properties["UserId"].Should().Be(123); + properties["Action"].Should().Be("Login"); + + // Template extraction + logEvent.GetTemplate().Should().Be("User {UserId} performed action {Action}"); + + // Message formatting + logMessage.ToString().Should().Be("User 123 performed action Login"); + } + + [Fact(DisplayName = "EventFilter should match semantic logging templates with named properties")] + public void EventFilter_should_match_semantic_templates() + { + // This test demonstrates the issue from GitHub #7932 + // EventFilter should match against the template pattern, not just the formatted output + + EventFilter.Info("OnCreateBet BetId:{BetId} created").ExpectOne(() => + { + Log.Info("OnCreateBet BetId:{BetId} created", 12345); + }); + } + + [Fact(DisplayName = "EventFilter should match semantic logging templates with contains")] + public void EventFilter_should_match_semantic_templates_with_contains() + { + EventFilter.Info(contains: "BetId:{BetId}").ExpectOne(() => + { + Log.Info("OnCreateBet BetId:{BetId} created", 12345); + }); + } + + [Fact(DisplayName = "EventFilter should match semantic logging templates with partial pattern")] + public void EventFilter_should_match_semantic_partial_pattern() + { + EventFilter.Info(start: "User {UserId}").ExpectOne(() => + { + Log.Info("User {UserId} logged in from {IpAddress}", 123, "192.168.1.1"); + }); + } + + [Fact(DisplayName = "EventFilter should still match formatted output when template doesn't match")] + public void EventFilter_should_fallback_to_formatted_output() + { + // Should also be able to match against the actual formatted values + EventFilter.Info(contains: "12345").ExpectOne(() => + { + Log.Info("OnCreateBet BetId:{BetId} created", 12345); + }); + } + + // BUG: Placeholder followed by }} fails + [Fact(DisplayName = "SemanticLogMessageFormatter should format '{UserId}}' with [123] to '123}'")] + public void Placeholder_followed_by_escaped_closing_brace_fails() + { + // CRITICAL: Parser treats }}} as "escaped brace", ignoring placeholder content + var propertyNames = MessageTemplateParser.GetPropertyNames("{UserId}}"); + propertyNames.Should().HaveCount(1, "parser must extract UserId"); + propertyNames[0].Should().Be("UserId"); + + var formatter = SemanticLogMessageFormatter.Instance; + var result = formatter.Format("{UserId}}", 123); + result.Should().Be("123}", "} closes placeholder, }} becomes }"); + } + + // BUG: Literal escaped braces not unescaped + [Fact(DisplayName = "SemanticLogMessageFormatter should format 'Use {{ and }}' to 'Use { and }'")] + public void Literal_escaped_braces_not_unescaped() + { + // CRITICAL: Templates without placeholders return raw string, no unescape + var formatter = SemanticLogMessageFormatter.Instance; + var result = formatter.Format("Use {{ and }} braces"); + result.Should().Be("Use { and } braces", "{{ → {, }} → }"); + } + + // BUG: Escaped braces around placeholders not unescaped + [Fact(DisplayName = "SemanticLogMessageFormatter should format '{First}}} text {{more {Second}' with [1, 2] to '1} text {more 2'")] + public void Escaped_braces_around_placeholders_not_unescaped() + { + // CRITICAL: Literal text between/after placeholders not processed for escaped braces + var formatter = SemanticLogMessageFormatter.Instance; + var result = formatter.Format("{First}}} text {{more {Second}", 1, 2); + result.Should().Be("1} text {more 2", "should unescape }} and {{ in literal text"); + } + + // BUG: Complex template {{{UserId}}} fails + [Fact(DisplayName = "SemanticLogMessageFormatter should format '{{{UserId}}}' with [123] to '{123}'")] + public void Complex_mixed_escaped_braces_and_placeholder_fails() + { + // CRITICAL: Combination of escaped braces + placeholder fails completely + var propertyNames = MessageTemplateParser.GetPropertyNames("{{{UserId}}}"); + propertyNames.Should().HaveCount(1, "must extract UserId"); + + var formatter = SemanticLogMessageFormatter.Instance; + var result = formatter.Format("{{{UserId}}}", 123); + result.Should().Be("{123}", "{{ → {, {UserId} → 123, }} → }"); + } + + // INVALID TEMPLATE: Empty property name with format specifier is not valid per Message Templates spec + // See: https://messagetemplates.org/ + // Property names must be valid identifiers - a format specifier alone ({:N2}) is malformed + [Fact(DisplayName = "Empty property name with format specifier {:N2} is invalid per spec")] + public void Empty_property_name_with_format_specifier_is_invalid() + { + // Per Message Templates spec, property names must be valid identifiers. + // {:N2} has no property name, only a format specifier - this is invalid. + // Current behavior: parser returns ":N2" as the property name (treating it as malformed but not crashing) + // This is acceptable "garbage in, garbage out" behavior for invalid templates. + var propertyNames = MessageTemplateParser.GetPropertyNames("{:N2}"); + + // We document but don't "fix" this - invalid templates have undefined behavior + propertyNames.Should().HaveCount(1, "parser extracts content even from invalid templates"); + // The colon is included because colonIndex > 0 check doesn't handle colon at position 0 + // This is intentional - we don't want to add complexity to handle invalid templates + } + + // BUG: Alignment specifiers ignored + [Fact(DisplayName = "SemanticLogMessageFormatter should format {Value,10:N2} with width and format")] + public void Alignment_specifiers_completely_ignored_in_named_templates() + { + // Per Message Templates spec, alignment IS supported: {PropertyName,Alignment:Format} + // Current code strips alignment but never applies it + var formatter = SemanticLogMessageFormatter.Instance; + + // Test 1: Simple alignment + var result1 = formatter.Format(">{Value,10}<", 123); + result1.Should().Be("> 123<", "positive alignment = right-align to 10 chars"); + + // Test 2: Negative alignment (left-align) + var result2 = formatter.Format(">{Value,-10}<", 123); + result2.Should().Be(">123 <", "negative alignment = left-align to 10 chars"); + + // Test 3: Combined alignment + format specifier + var result3 = formatter.Format("{Value,10:N2}", 123.456); + result3.Should().HaveLength(10, "alignment width must be applied"); + result3.Should().MatchRegex(@"^\s+\d{3}[.,]\d{2}$", "should be right-aligned with 2 decimals"); + } + + // BUG: ToString() returning null causes silent data loss + [Fact(DisplayName = "SemanticLogMessageFormatter should format ToString() returning null correctly")] + public void ToString_returning_null_should_be_handled() + { + // If type's ToString() returns null (violates .NET guidelines but possible), + // value silently disappears instead of showing "null" + var formatter = SemanticLogMessageFormatter.Instance; + var badObject = new TypeWithNullToString(); + + // Without format specifier - exercises line 258 + var result1 = formatter.Format("{Value}", badObject); + result1.Should().Be("null", "ToString() null should be treated as explicit null"); + + // With format specifier - exercises catch block line 253 + var result2 = formatter.Format("{Value:N2}", badObject); + result2.Should().Be("null", "ToString() null in catch block should be handled"); + } + + // Helper class for testing ToString() returning null + private class TypeWithNullToString + { + public override string ToString() => null; + } + } +} diff --git a/src/core/Akka.Tests/Loggers/StandardOutWriterSpec.cs b/src/core/Akka.Tests/Loggers/StandardOutWriterSpec.cs new file mode 100644 index 00000000000..9475bdb466c --- /dev/null +++ b/src/core/Akka.Tests/Loggers/StandardOutWriterSpec.cs @@ -0,0 +1,94 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.IO; +using System.Threading.Tasks; +using Akka.TestKit; +using Akka.Util; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Tests.Loggers +{ + /// + /// Tests for StandardOutWriter to ensure it handles IIS/Windows Service environments correctly + /// where Console.Out and Console.Error may be redirected to StreamWriter.Null + /// + public class StandardOutWriterSpec : AkkaSpec + { + public StandardOutWriterSpec(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void StandardOutWriter_should_handle_concurrent_writes_without_race_conditions() + { + // This test simulates the concurrent access pattern that causes issues in IIS + // In normal test environments this won't reproduce the issue, but it ensures + // our fix doesn't break normal console operation + + var tasks = new Task[100]; + + for (int i = 0; i < tasks.Length; i++) + { + var taskId = i; + tasks[i] = Task.Run(() => + { + for (int j = 0; j < 10; j++) + { + // These calls should not throw even under concurrent access + StandardOutWriter.WriteLine($"Task {taskId} - Line {j}"); + StandardOutWriter.Write($"Task {taskId} - Write {j} "); + } + }); + } + + // Should complete without throwing IndexOutOfRangeException + Assert.True(Task.WaitAll(tasks, TimeSpan.FromSeconds(5))); + } + + [Fact] + public void StandardOutWriter_should_not_throw_when_console_is_redirected() + { + // Save original streams + var originalOut = Console.Out; + var originalError = Console.Error; + + try + { + // Simulate IIS/Windows Service environment by redirecting to null + Console.SetOut(StreamWriter.Null); + Console.SetError(StreamWriter.Null); + + // These should not throw even when console is redirected to null + StandardOutWriter.WriteLine("This should not throw"); + StandardOutWriter.Write("Neither should this"); + + // Test with colors (which would normally fail in IIS) + StandardOutWriter.WriteLine("Colored output", ConsoleColor.Red); + StandardOutWriter.Write("Colored write", ConsoleColor.Blue, ConsoleColor.Yellow); + } + finally + { + // Restore original streams + Console.SetOut(originalOut); + Console.SetError(originalError); + } + } + + [Fact] + public void StandardOutWriter_should_handle_null_and_empty_messages() + { + // Should not throw + StandardOutWriter.WriteLine(null); + StandardOutWriter.WriteLine(""); + StandardOutWriter.Write(null); + StandardOutWriter.Write(""); + } + } +} \ No newline at end of file diff --git a/src/core/Akka/Actor/ActorCell.Children.cs b/src/core/Akka/Actor/ActorCell.Children.cs index 5b56c397ad4..c83e172857c 100644 --- a/src/core/Akka/Actor/ActorCell.Children.cs +++ b/src/core/Akka/Actor/ActorCell.Children.cs @@ -98,6 +98,22 @@ public virtual IActorRef AttachChild(Props props, bool isSystemService, string? { return MakeChild(props, name == null ? GetRandomActorName() : CheckName(name), true, isSystemService); } + + /// + /// INTERNAL API + /// + /// Attaches a child actor with explicit control over async initialization. + /// Used by TestKit to create TestActors synchronously to avoid deadlocks. + /// + /// The this child actor will use. + /// If true, then this actor is a system actor and skips serialization checks. + /// If true, creates RepointableActorRef with async init. If false, creates LocalActorRef synchronously. + /// The name of the actor being started. Can be null for auto-generated name. + /// A reference to the initialized child actor. + internal IActorRef AttachChildWithAsync(Props props, bool isSystemService, bool isAsync, string? name = null) + { + return MakeChild(props, name == null ? GetRandomActorName() : CheckName(name), isAsync, isSystemService); + } /// /// TBD diff --git a/src/core/Akka/Actor/Settings.cs b/src/core/Akka/Actor/Settings.cs index 120663f5dae..3b180ba62be 100644 --- a/src/core/Akka/Actor/Settings.cs +++ b/src/core/Akka/Actor/Settings.cs @@ -182,6 +182,11 @@ public Settings(ActorSystem system, Config config, ActorSystemSetup setup) { LogFormatter = DefaultLogMessageFormatter.Instance; } + // SPECIAL CASE - check for the semantic log message formatter, which does not have an empty constructor (it's private) + else if (logFormatType == typeof(SemanticLogMessageFormatter)) + { + LogFormatter = SemanticLogMessageFormatter.Instance; + } else { try diff --git a/src/core/Akka/Configuration/akka.conf b/src/core/Akka/Configuration/akka.conf index df5704d8f94..86bb15706a9 100644 --- a/src/core/Akka/Configuration/akka.conf +++ b/src/core/Akka/Configuration/akka.conf @@ -32,7 +32,7 @@ akka { # Specifies the formatter used to format log messages. Can be customized # to use a different logging implementation, such as Serilog. - logger-formatter = "Akka.Event.DefaultLogMessageFormatter, Akka" + logger-formatter = "Akka.Event.SemanticLogMessageFormatter, Akka" # Log level used by the configured loggers (see "loggers") as soon # as they have been started; before that, see "stdout-loglevel" diff --git a/src/core/Akka/Event/DefaultLogger.cs b/src/core/Akka/Event/DefaultLogger.cs index 129b844deae..3d87cec932e 100644 --- a/src/core/Akka/Event/DefaultLogger.cs +++ b/src/core/Akka/Event/DefaultLogger.cs @@ -46,7 +46,11 @@ protected override bool Receive(object message) protected virtual void Print(LogEvent logEvent) { if (_stdoutLogger == null) - throw new Exception("Logger has not been initialized yet."); + { + // Include context about the failed log event to help with debugging + var logDetails = $"[{logEvent.LogLevel()}] {logEvent.LogSource}: {logEvent.Message}"; + throw new Exception($"Logger has not been initialized yet. Failed to log: {logDetails}"); + } _stdoutLogger.Tell(logEvent); } diff --git a/src/core/Akka/Event/LogEventExtensions.cs b/src/core/Akka/Event/LogEventExtensions.cs new file mode 100644 index 00000000000..9d8d5685070 --- /dev/null +++ b/src/core/Akka/Event/LogEventExtensions.cs @@ -0,0 +1,111 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2025 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Akka.Event +{ + /// + /// Extension methods for accessing semantic logging properties from instances. + /// These methods make it easy for custom logger implementations to extract structured properties. + /// + public static class LogEventExtensions + { + /// + /// Attempts to extract structured properties from the log event message. + /// + /// The log event + /// The extracted properties dictionary (if successful) + /// True if properties were extracted, false if message is a pre-formatted string + /// + /// + /// if (logEvent.TryGetProperties(out var properties)) + /// { + /// // Use structured properties with your native logger + /// foreach (var prop in properties) + /// { + /// Console.WriteLine($"{prop.Key} = {prop.Value}"); + /// } + /// } + /// + /// + public static bool TryGetProperties( + this LogEvent evt, + out IReadOnlyDictionary? properties) + { + if (evt.Message is LogMessage msg) + { + properties = msg.GetProperties(); + return true; + } + + properties = null; + return false; + } + + /// + /// Gets the property names from the log event's message template. + /// Returns empty list if message is a pre-formatted string. + /// + /// The log event + /// List of property names, or empty list + /// + /// + /// var names = logEvent.GetPropertyNames(); + /// // For "User {UserId} logged in", returns ["UserId"] + /// + /// + public static IReadOnlyList GetPropertyNames(this LogEvent evt) + { + return evt.Message is LogMessage msg + ? msg.PropertyNames + : Array.Empty(); + } + + /// + /// Gets the message template format string from the log event. + /// + /// The log event + /// Template string if LogMessage, otherwise the string representation + /// + /// + /// var template = logEvent.GetTemplate(); + /// // For semantic logs, returns "User {UserId} logged in" + /// // For pre-formatted strings, returns the actual message + /// + /// + public static string GetTemplate(this LogEvent evt) + { + return evt.Message is LogMessage msg + ? msg.Format + : evt.Message?.ToString() ?? string.Empty; + } + + /// + /// Gets the parameter values from the log message. + /// Returns empty enumerable if message is a pre-formatted string. + /// + /// The log event + /// Parameter values, or empty enumerable + /// + /// + /// var parameters = logEvent.GetParameters().ToArray(); + /// // For log.Info("User {0}", 123), returns [123] + /// + /// + public static IEnumerable GetParameters(this LogEvent evt) + { + return evt.Message is LogMessage msg + ? msg.Parameters() + : Enumerable.Empty(); + } + } +} diff --git a/src/core/Akka/Event/LogMessage.cs b/src/core/Akka/Event/LogMessage.cs index 955541dfe52..64eb3634c36 100644 --- a/src/core/Akka/Event/LogMessage.cs +++ b/src/core/Akka/Event/LogMessage.cs @@ -25,16 +25,35 @@ public static class LogMessageExtensions{ /// /// /// Call ToString to get the formatted output. + /// Supports semantic logging by extracting property names from message templates. /// public abstract class LogMessage { protected readonly ILogMessageFormatter Formatter; + private IReadOnlyList? _propertyNames; + private IReadOnlyDictionary? _properties; /// /// Gets the format string of this log message. /// public string Format { get; private set; } + /// + /// Gets the property names extracted from the message template. + /// For positional templates like "{0} and {1}", returns ["0", "1"]. + /// For named templates like "{UserId} logged in", returns ["UserId"]. + /// This property uses lazy initialization and caching for performance. + /// + public IReadOnlyList PropertyNames + { + get + { + if (_propertyNames == null) + _propertyNames = MessageTemplateParser.GetPropertyNames(Format); + return _propertyNames; + } + } + /// /// Initializes an instance of the LogMessage with the specified formatter, format and args. /// @@ -46,6 +65,98 @@ public LogMessage(ILogMessageFormatter formatter, string format) Format = format; } + /// + /// Gets a dictionary of property names to their values. + /// Combines PropertyNames with Parameters() to create name-value pairs. + /// This method uses lazy initialization and caching for performance. + /// + /// A read-only dictionary of property names and values + public IReadOnlyDictionary GetProperties() + { + if (_properties == null) + { + var names = PropertyNames; + var parameters = Parameters(); + + // Optimize: avoid ToArray() if Parameters() already returns IReadOnlyList + if (parameters is IReadOnlyList readOnlyList) + { + _properties = CreatePropertyDictionary(names, readOnlyList); + } + else if (parameters is object[] array) + { + _properties = CreatePropertyDictionary(names, array); + } + else + { + // Fallback: convert to array + _properties = CreatePropertyDictionary(names, parameters.ToArray()); + } + } + return _properties; + } + + private static IReadOnlyDictionary CreatePropertyDictionary( + IReadOnlyList names, + IReadOnlyList values) + { + // Handle empty case + if (names.Count == 0) + return EmptyDictionary; + + // Handle mismatched counts (more values than names, or vice versa) + var count = Math.Min(names.Count, values.Count); + if (count == 0) + return EmptyDictionary; + + var dict = new Dictionary(count); + for (int i = 0; i < count; i++) + { + dict[names[i]] = values[i]; + } + +#if NET8_0_OR_GREATER + // Use FrozenDictionary for optimal read performance on .NET 8+ + return System.Collections.Frozen.FrozenDictionary.ToFrozenDictionary(dict); +#else + return dict; +#endif + } + + private static IReadOnlyDictionary CreatePropertyDictionary( + IReadOnlyList names, + object[] values) + { + // Handle empty case + if (names.Count == 0) + return EmptyDictionary; + + // Handle mismatched counts (more values than names, or vice versa) + var count = Math.Min(names.Count, values.Length); + if (count == 0) + return EmptyDictionary; + + var dict = new Dictionary(count); + for (int i = 0; i < count; i++) + { + dict[names[i]] = values[i]; + } + +#if NET8_0_OR_GREATER + // Use FrozenDictionary for optimal read performance on .NET 8+ + return System.Collections.Frozen.FrozenDictionary.ToFrozenDictionary(dict); +#else + return dict; +#endif + } + + private static readonly IReadOnlyDictionary EmptyDictionary = +#if NET8_0_OR_GREATER + System.Collections.Frozen.FrozenDictionary.Empty; +#else + new Dictionary(); +#endif + /// /// INTERNAL API /// diff --git a/src/core/Akka/Event/MessageTemplateParser.cs b/src/core/Akka/Event/MessageTemplateParser.cs new file mode 100644 index 00000000000..be04997ad25 --- /dev/null +++ b/src/core/Akka/Event/MessageTemplateParser.cs @@ -0,0 +1,213 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2025 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Akka.Event +{ + /// + /// Parses message templates to extract property names for semantic logging. + /// Supports both positional templates ({0}, {1}) and named templates ({PropertyName}). + /// Uses ThreadStatic caching for performance. + /// + internal static class MessageTemplateParser + { + [ThreadStatic] + private static LruCache? _cache; + + private const int MaxCacheSize = 1000; + + private static LruCache Cache + { + get + { + if (_cache == null) + _cache = new LruCache(MaxCacheSize); + return _cache; + } + } + + /// + /// Gets the property names from a message template. + /// For positional templates like "{0} and {1}", returns ["0", "1"]. + /// For named templates like "{UserId} logged in", returns ["UserId"]. + /// Results are cached for performance. + /// + /// The message template string + /// List of property names + public static IReadOnlyList GetPropertyNames(string template) + { + if (string.IsNullOrEmpty(template)) + return Array.Empty(); + + var hash = template.GetHashCode(); + + // Try cache first + if (Cache.TryGet(hash, out var cached) && cached.Template == template) + return cached.PropertyNames; + + // Parse and cache + var propertyNames = ParseTemplate(template); + var parsed = new ParsedTemplate(template, propertyNames); + Cache.Add(hash, parsed); + + return propertyNames; + } + + /// + /// Parses a message template to extract property names. + /// + private static IReadOnlyList ParseTemplate(string template) + { + var properties = new List(); + var length = template.Length; + var i = 0; + + while (i < length) + { + var openBrace = template.IndexOf('{', i); + if (openBrace == -1) + break; + + // Check for escaped brace {{ + if (openBrace + 1 < length && template[openBrace + 1] == '{') + { + i = openBrace + 2; + continue; + } + + var closeBrace = template.IndexOf('}', openBrace + 1); + if (closeBrace == -1) + break; // Malformed template, stop parsing + + // Note: We do NOT check for }} here. The }} escape sequence only applies to literal + // text during formatting, not during property name extraction. After finding a valid + // placeholder {Name}, any subsequent } is a literal character, not an escape. + // For example: "{UserId}}" has placeholder "UserId" followed by literal "}" + + // Extract property name + var propertyLength = closeBrace - openBrace - 1; + if (propertyLength > 0) + { + var propertyName = template.Substring(openBrace + 1, propertyLength).Trim(); + + // Remove format specifiers (e.g., {Value:N2} -> Value) + var colonIndex = propertyName.IndexOf(':'); + if (colonIndex > 0) + propertyName = propertyName.Substring(0, colonIndex).Trim(); + + // Remove alignment specifiers (e.g., {Value,10} -> Value) + var commaIndex = propertyName.IndexOf(','); + if (commaIndex > 0) + propertyName = propertyName.Substring(0, commaIndex).Trim(); + + if (!string.IsNullOrEmpty(propertyName)) + properties.Add(propertyName); + } + + i = closeBrace + 1; + } + + return properties.ToArray(); + } + } + + /// + /// Represents a parsed message template with property names. + /// + internal sealed class ParsedTemplate + { + public string Template { get; } + public IReadOnlyList PropertyNames { get; } + + public ParsedTemplate(string template, IReadOnlyList propertyNames) + { + Template = template; + PropertyNames = propertyNames; + } + } + + /// + /// Simple LRU (Least Recently Used) cache implementation. + /// + internal sealed class LruCache where TKey : notnull + { + private readonly int _maxSize; + private readonly Dictionary> _cache; + private readonly LinkedList _lruList; + + public LruCache(int maxSize) + { + _maxSize = maxSize; + _cache = new Dictionary>(maxSize); + _lruList = new LinkedList(); + } + + /// + /// Tries to get a value from the cache. + /// If found, moves the entry to the front (most recently used). + /// + public bool TryGet(TKey key, out TValue value) + { + if (_cache.TryGetValue(key, out var node)) + { + // Move to front (most recently used) + _lruList.Remove(node); + _lruList.AddFirst(node); + + value = node.Value.Value; + return true; + } + + value = default!; + return false; + } + + /// + /// Adds a value to the cache. + /// If at capacity, evicts the least recently used entry. + /// + public void Add(TKey key, TValue value) + { + // If key already exists, update it + if (_cache.TryGetValue(key, out var existingNode)) + { + _lruList.Remove(existingNode); + _cache.Remove(key); + } + // Evict oldest if at capacity + else if (_cache.Count >= _maxSize) + { + var oldest = _lruList.Last; + if (oldest != null) + { + _lruList.RemoveLast(); + _cache.Remove(oldest.Value.Key); + } + } + + // Add new entry + var entry = new CacheEntry(key, value); + var node = _lruList.AddFirst(entry); + _cache[key] = node; + } + + private struct CacheEntry + { + public TKey Key { get; } + public TValue Value { get; } + + public CacheEntry(TKey key, TValue value) + { + Key = key; + Value = value; + } + } + } +} diff --git a/src/core/Akka/Event/SemanticLogMessageFormatter.cs b/src/core/Akka/Event/SemanticLogMessageFormatter.cs new file mode 100644 index 00000000000..73c3df71dd1 --- /dev/null +++ b/src/core/Akka/Event/SemanticLogMessageFormatter.cs @@ -0,0 +1,494 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2025 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Akka.Event +{ + /// + /// Message formatter that supports semantic logging with both positional and named templates. + /// Implements the Message Templates specification, + /// which is the language-neutral standard used by Serilog, Microsoft.Extensions.Logging, NLog, + /// and other structured logging frameworks. + /// + /// + /// Supported syntax: + /// + /// Named properties: {PropertyName} + /// Positional properties: {0}, {1} + /// Format specifiers: {Value:N2}, {Date:yyyy-MM-dd} + /// Alignment: {Value,10}, {Value,-10} + /// Escaped braces: {{{, }}} + /// + /// Not supported: + /// + /// Destructuring operators: {@Object}, {$Object} (Serilog-specific) + /// Empty property names: {:N2} (invalid per spec) + /// + /// + public sealed class SemanticLogMessageFormatter : ILogMessageFormatter + { + /// + /// Gets the singleton instance of the . + /// + public static readonly SemanticLogMessageFormatter Instance = new(); + + private SemanticLogMessageFormatter() + { + } + + /// + /// Formats a log message using the specified format string and arguments. + /// + /// The format string (supports both {0} and {PropertyName} styles) + /// The arguments to format + /// The formatted message string + public string Format(string format, params object[] args) + { + return Format(format, (IEnumerable)args); + } + + /// + /// Formats a log message using the specified format string and arguments. + /// + /// The format string (supports both {0} and {PropertyName} styles) + /// The arguments to format + /// The formatted message string + public string Format(string format, IEnumerable args) + { + if (string.IsNullOrEmpty(format)) + return string.Empty; + + // Optimize: avoid ToArray() if args is already an array or IReadOnlyList + object[] argArray; + if (args == null) + { + argArray = Array.Empty(); + } + else if (args is object[] array) + { + argArray = array; + } + else if (args is IReadOnlyList readOnlyList) + { + // LogValues structs implement IReadOnlyList, use them directly + // Only convert to array for string.Format which requires object[] + var propertyNames = MessageTemplateParser.GetPropertyNames(format); + if (propertyNames.Count == 0) + return UnescapeBraces(format); + + var isPositional = propertyNames.Count > 0 && int.TryParse(propertyNames[0], out _); + + if (isPositional) + { + // string.Format requires object[], so convert here only + argArray = new object[readOnlyList.Count]; + for (int i = 0; i < readOnlyList.Count; i++) + argArray[i] = readOnlyList[i]; + + // For positional templates, use string.Format directly without catching FormatException + // to maintain backward compatibility with DefaultLogMessageFormatter behavior + return string.Format(format, argArray); + } + else + { + // Named template - use IReadOnlyList directly + return FormatNamedTemplate(format, propertyNames, readOnlyList); + } + } + else + { + argArray = args.ToArray(); + } + + if (argArray.Length == 0) + return UnescapeBraces(format); + + // Get property names from the template + var propertyNames2 = MessageTemplateParser.GetPropertyNames(format); + if (propertyNames2.Count == 0) + return UnescapeBraces(format); + + // Check if this is a positional template or named template + var isPositional2 = propertyNames2.Count > 0 && int.TryParse(propertyNames2[0], out _); + + if (isPositional2) + { + // For positional templates, use string.Format directly without catching FormatException + // to maintain backward compatibility with DefaultLogMessageFormatter behavior + return string.Format(format, argArray); + } + else + { + // Named template - do semantic substitution + return FormatNamedTemplate(format, propertyNames2, argArray); + } + } + + /// + /// Unescapes {{ to { and }} to } in a string that has no placeholders. + /// + private static string UnescapeBraces(string format) + { + // Fast path: if no escaped braces, return as-is + if (format.IndexOf('{') == -1 && format.IndexOf('}') == -1) + return format; + + var result = new StringBuilder(format.Length); + var length = format.Length; + var i = 0; + + while (i < length) + { + var ch = format[i]; + + if (ch == '{' && i + 1 < length && format[i + 1] == '{') + { + result.Append('{'); + i += 2; + } + else if (ch == '}' && i + 1 < length && format[i + 1] == '}') + { + result.Append('}'); + i += 2; + } + else + { + result.Append(ch); + i++; + } + } + + return result.ToString(); + } + + /// + /// Formats a named template by replacing {PropertyName} with values. + /// Handles escaped braces: {{ → {, }} → } + /// + private static string FormatNamedTemplate(string format, IReadOnlyList propertyNames, IReadOnlyList args) + { + var result = new StringBuilder(format.Length + args.Count * 10); + var length = format.Length; + var i = 0; + var argIndex = 0; + + while (i < length) + { + var ch = format[i]; + + // Check for escaped }} in literal text + if (ch == '}' && i + 1 < length && format[i + 1] == '}') + { + result.Append('}'); + i += 2; + continue; + } + + // Check for placeholder start + if (ch == '{') + { + // Check for escaped brace {{ + if (i + 1 < length && format[i + 1] == '{') + { + result.Append('{'); + i += 2; + continue; + } + + // Find closing brace for placeholder + var closeBrace = format.IndexOf('}', i + 1); + if (closeBrace == -1) + { + // Malformed template, append rest and break + result.Append(format.Substring(i)); + break; + } + + // Extract the placeholder content + var placeholderLength = closeBrace - i - 1; + if (placeholderLength > 0) + { + var placeholder = format.Substring(i + 1, placeholderLength).Trim(); + + // Parse placeholder: {Name,alignment:format} + // First, find the property name (before comma or colon) + var commaIndex = placeholder.IndexOf(','); + var colonIndex = placeholder.IndexOf(':'); + string propertyName; + string alignmentSpec = null; + string formatSpec = null; + + // Determine the property name endpoint + var endOfName = placeholder.Length; + if (commaIndex >= 0 && (colonIndex < 0 || commaIndex < colonIndex)) + { + // Comma comes first (or no colon) + endOfName = commaIndex; + } + else if (colonIndex >= 0) + { + // Colon comes first (or no comma) + endOfName = colonIndex; + } + + propertyName = placeholder.Substring(0, endOfName).Trim(); + + // Extract alignment if present + if (commaIndex >= 0) + { + var alignmentStart = commaIndex + 1; + var alignmentEnd = colonIndex >= 0 ? colonIndex : placeholder.Length; + alignmentSpec = placeholder.Substring(alignmentStart, alignmentEnd - alignmentStart).Trim(); + } + + // Extract format specifier if present + if (colonIndex >= 0) + { + formatSpec = placeholder.Substring(colonIndex + 1).Trim(); + } + + placeholder = propertyName; + + // Substitute the value + if (argIndex < args.Count) + { + var value = args[argIndex]; + string formattedValue; + + if (value != null) + { + // First get the string representation + var strValue = value.ToString(); + + if (strValue != null) + { + // Apply format specifier if present + if (!string.IsNullOrEmpty(formatSpec)) + { + try + { + formattedValue = string.Format($"{{0:{formatSpec}}}", value); + } + catch + { + // If formatting fails, use the plain string + formattedValue = strValue; + } + } + else + { + formattedValue = strValue; + } + } + else + { + // ToString() returned null + formattedValue = "null"; + } + } + else + { + formattedValue = "null"; + } + + // Apply alignment if present + if (!string.IsNullOrEmpty(alignmentSpec) && int.TryParse(alignmentSpec, out var alignment)) + { + formattedValue = alignment > 0 + ? formattedValue.PadLeft(alignment) + : formattedValue.PadRight(-alignment); + } + + result.Append(formattedValue); + argIndex++; + } + else + { + // Not enough args, keep the placeholder + result.Append('{').Append(placeholder).Append('}'); + } + } + + i = closeBrace + 1; + } + else + { + // Regular character + result.Append(ch); + i++; + } + } + + return result.ToString(); + } + + /// + /// Formats a named template by replacing {PropertyName} with values. + /// Handles escaped braces: {{ → {, }} → } + /// + private static string FormatNamedTemplate(string format, IReadOnlyList propertyNames, object[] args) + { + var result = new StringBuilder(format.Length + args.Length * 10); + var length = format.Length; + var i = 0; + var argIndex = 0; + + while (i < length) + { + var ch = format[i]; + + // Check for escaped }} in literal text + if (ch == '}' && i + 1 < length && format[i + 1] == '}') + { + result.Append('}'); + i += 2; + continue; + } + + // Check for placeholder start + if (ch == '{') + { + // Check for escaped brace {{ + if (i + 1 < length && format[i + 1] == '{') + { + result.Append('{'); + i += 2; + continue; + } + + // Find closing brace for placeholder + var closeBrace = format.IndexOf('}', i + 1); + if (closeBrace == -1) + { + // Malformed template, append rest and break + result.Append(format.Substring(i)); + break; + } + + // Extract the placeholder content + var placeholderLength = closeBrace - i - 1; + if (placeholderLength > 0) + { + var placeholder = format.Substring(i + 1, placeholderLength).Trim(); + + // Parse placeholder: {Name,alignment:format} + // First, find the property name (before comma or colon) + var commaIndex = placeholder.IndexOf(','); + var colonIndex = placeholder.IndexOf(':'); + string propertyName; + string alignmentSpec = null; + string formatSpec = null; + + // Determine the property name endpoint + var endOfName = placeholder.Length; + if (commaIndex >= 0 && (colonIndex < 0 || commaIndex < colonIndex)) + { + // Comma comes first (or no colon) + endOfName = commaIndex; + } + else if (colonIndex >= 0) + { + // Colon comes first (or no comma) + endOfName = colonIndex; + } + + propertyName = placeholder.Substring(0, endOfName).Trim(); + + // Extract alignment if present + if (commaIndex >= 0) + { + var alignmentStart = commaIndex + 1; + var alignmentEnd = colonIndex >= 0 ? colonIndex : placeholder.Length; + alignmentSpec = placeholder.Substring(alignmentStart, alignmentEnd - alignmentStart).Trim(); + } + + // Extract format specifier if present + if (colonIndex >= 0) + { + formatSpec = placeholder.Substring(colonIndex + 1).Trim(); + } + + placeholder = propertyName; + + // Substitute the value + if (argIndex < args.Length) + { + var value = args[argIndex]; + string formattedValue; + + if (value != null) + { + // First get the string representation + var strValue = value.ToString(); + + if (strValue != null) + { + // Apply format specifier if present + if (!string.IsNullOrEmpty(formatSpec)) + { + try + { + formattedValue = string.Format($"{{0:{formatSpec}}}", value); + } + catch + { + // If formatting fails, use the plain string + formattedValue = strValue; + } + } + else + { + formattedValue = strValue; + } + } + else + { + // ToString() returned null + formattedValue = "null"; + } + } + else + { + formattedValue = "null"; + } + + // Apply alignment if present + if (!string.IsNullOrEmpty(alignmentSpec) && int.TryParse(alignmentSpec, out var alignment)) + { + formattedValue = alignment > 0 + ? formattedValue.PadLeft(alignment) + : formattedValue.PadRight(-alignment); + } + + result.Append(formattedValue); + argIndex++; + } + else + { + // Not enough args, keep the placeholder + result.Append('{').Append(placeholder).Append('}'); + } + } + + i = closeBrace + 1; + } + else + { + // Regular character + result.Append(ch); + i++; + } + } + + return result.ToString(); + } + } +} diff --git a/src/core/Akka/Event/StandardOutLogger.cs b/src/core/Akka/Event/StandardOutLogger.cs index 037cab22c41..1215fffedae 100644 --- a/src/core/Akka/Event/StandardOutLogger.cs +++ b/src/core/Akka/Event/StandardOutLogger.cs @@ -6,6 +6,7 @@ //----------------------------------------------------------------------- using System; +using System.Linq; using Akka.Actor; using Akka.Util; using System.Text; @@ -126,7 +127,7 @@ internal static void PrintLogEvent(LogEvent logEvent, LogFilterEvaluator filter) // short circuit if we're not going to print this message if (!filter.ShouldTryKeepMessage(logEvent, out var expandedLogMessage)) return; - + ConsoleColor? color = null; if (UseColors) diff --git a/src/core/Akka/Properties/AssemblyInfo.cs b/src/core/Akka/Properties/AssemblyInfo.cs index 5ecfbb2329d..377877ae038 100644 --- a/src/core/Akka/Properties/AssemblyInfo.cs +++ b/src/core/Akka/Properties/AssemblyInfo.cs @@ -28,6 +28,8 @@ [assembly: InternalsVisibleTo("Akka.Tests.Performance")] [assembly: InternalsVisibleTo("Akka.TestKit")] [assembly: InternalsVisibleTo("Akka.TestKit.Tests")] +[assembly: InternalsVisibleTo("Akka.TestKit.Xunit")] +[assembly: InternalsVisibleTo("Akka.TestKit.Xunit2")] [assembly: InternalsVisibleTo("Akka.Remote")] [assembly: InternalsVisibleTo("Akka.Remote.TestKit")] [assembly: InternalsVisibleTo("Akka.Remote.Tests")] diff --git a/src/core/Akka/Util/StandardOutWriter.cs b/src/core/Akka/Util/StandardOutWriter.cs index a049c308bbe..d0495dd9685 100644 --- a/src/core/Akka/Util/StandardOutWriter.cs +++ b/src/core/Akka/Util/StandardOutWriter.cs @@ -6,6 +6,7 @@ //----------------------------------------------------------------------- using System; +using System.IO; namespace Akka.Util { @@ -16,6 +17,34 @@ namespace Akka.Util public static class StandardOutWriter { private static readonly object _lock = new(); + private static readonly bool _isConsoleAvailable = DetectConsoleAvailability(); + + /// + /// Detects whether a real console is available for output. + /// In environments like IIS and Windows Services, console output is redirected to StreamWriter.Null, + /// which is a singleton. When multiple threads write to both Console.Out and Console.Error + /// (which point to the same StreamWriter.Null instance), it causes race conditions. + /// + /// Since console output goes nowhere in these environments anyway, we skip it entirely + /// to prevent the race condition and improve performance. + /// + private static bool DetectConsoleAvailability() + { + // Specifically detect the IIS/Windows Service scenario where both Console.Out + // and Console.Error point to the SAME StreamWriter.Null singleton instance. + // This is the exact condition that causes the race condition. + // Note: We check both because in these environments, both are always set to the same instance + if (Console.Out == StreamWriter.Null && Console.Error == StreamWriter.Null) + return false; + + // Also check Environment.UserInteractive for additional safety + // This returns false for Windows Services and IIS in .NET Framework + // (though less reliable in .NET Core, the StreamWriter.Null check above is the key) + if (!Environment.UserInteractive) + return false; + + return true; + } /// /// Writes the specified value to the standard output stream. Optionally @@ -46,6 +75,16 @@ public static void WriteLine(string message, ConsoleColor? foregroundColor = nul private static void WriteToConsole(string message, ConsoleColor? foregroundColor = null, ConsoleColor? backgroundColor = null, bool line = true) { + // Skip console output in IIS, Windows Services, and other non-console environments. + // In these environments: + // 1. Console output is redirected to StreamWriter.Null (goes nowhere anyway) + // 2. Both Console.Out and Console.Error point to the same StreamWriter.Null singleton + // 3. Concurrent writes to both streams cause race conditions and IndexOutOfRangeException + // 4. Skipping output entirely prevents the race condition and improves performance + // See: https://github.com/akkadotnet/akka.net/issues/7691 + if (!_isConsoleAvailable) + return; + lock (_lock) { ConsoleColor? fg = null;