diff --git a/.github/instructions/source-connection--csharp.instructions.md b/.github/instructions/source-connection--csharp.instructions.md index 29a56c4..b97fabc 100644 --- a/.github/instructions/source-connection--csharp.instructions.md +++ b/.github/instructions/source-connection--csharp.instructions.md @@ -51,7 +51,9 @@ Every `.cs` starts with the Apache 2.0 header exactly as in the neighbouring fil `internal sealed`; the shared base is `internal abstract`. **Exceptions to know:** `InfoShareOpenIdConnectSystemBrowser` is `public` (it implements Duende's `IBrowser`), `InfoShareOpenIdConnectLocalHttpEndpoint` and `IshConnectionConfiguration` are `internal` (not - sealed). Don't widen visibility without a reason. + sealed), `RetryOnFaultProxy` is `public` (not sealed) because .NET Framework's + `DispatchProxy.Create()` fails to load a non-public `TProxy` with "Access is denied". + Don't widen visibility without a reason. - Constructor signature for the three connection front-ends is uniform: `(ILogger logger, HttpClient httpClient, <…ConnectionParameters> parameters)`. The `HttpClient` is **created once by `IshSession` and reused** (TLS/SSL already initialized) — never new up your own @@ -120,6 +122,21 @@ and `#if NET10_0_OR_GREATER` for narrower cases). Both arms must compile **and b (`OpenApiISH30Client`, `OpenApiAM10Client`), setting the `Bearer` header from `GetAccessToken()`; **inherits** the OIDC base; `IDisposable`. +**Cross-cutting helper** +- `RetryOnFaultProxy.cs` — generic `System.Reflection.DispatchProxy`-based wrapper used only by + `InfoShareWcfSoapWithOpenIdConnectConnection`'s `Get*25Channel()` accessors: catches + `CommunicationException`/`FaultException` on the actual SOAP call, rebuilds the channel via the + caller-supplied delegate, and retries exactly once, unwrapping reflection's + `TargetInvocationException` so the original exception type/stack trace reaches the caller + unchanged. Wraps only the plain service-contract interface, never `ICommunicationObject`/ + `IDisposable` — those keep operating on the private raw channel field, unaffected. Each public + `Get*25Channel()` is split from a private `Ensure*25Channel()` that does the state-check/rebuild + and returns the **raw, unwrapped** channel; `Get*25Channel()` wraps exactly once and passes + `Ensure*25Channel` (not itself) as the rebuild delegate. **Never** pass a rebuild delegate that + returns an already-wrapped proxy — each retry would then recurse into a brand-new + `RetryOnFaultProxy` with its own full retry budget, chaining unbounded on a persistent (non- + transient) fault and causing a stack overflow instead of one bounded retry. + **Diagram** - `__ConnectionClassDiagram.cd` — Visual Studio class diagram. **Keep it in sync** when you add, remove, or rename a class in this folder. @@ -151,6 +168,8 @@ When you copy `Connection/` out, these are the only external seams to satisfy own generated service references. - The NSwag OpenAPI clients `Trisoft.ISHRemote.OpenApiISH30` / `…OpenApiAM10`. - NuGet: `Duende.IdentityModel` + `Duende.IdentityModel.OidcClient`, and `Newtonsoft.Json`. +- NuGet: `System.Reflection.DispatchProxy` on `net48` only (built into the BCL on `net6.0`+), used by + `RetryOnFaultProxy.cs`. If you keep those seams thin, the WS-Trust, OIDC-over-SOAP and OIDC-over-OpenAPI flavours — with Client Credentials or interactive browser auth — all come across intact. diff --git a/Doc/ReleaseNotes-ISHRemote-8.3.md b/Doc/ReleaseNotes-ISHRemote-8.3.md index f57a536..2e78645 100644 --- a/Doc/ReleaseNotes-ISHRemote-8.3.md +++ b/Doc/ReleaseNotes-ISHRemote-8.3.md @@ -30,6 +30,7 @@ The below text describes the delta compared to fielded release ISHRemote v8.2. * Fixed `Start-IshRemoteMcpServer` failing to connect on Windows with newer MCP clients (e.g. OpenCode 1.18.11, protocol `2025-11-25`) with errors `MCP error -32001: Request timed out` and `Failed to get tools`. Three root causes: (1) `initialize` requests with `"id":0` were silently dropped because PowerShell treats `0` as falsy; (2) `[Console]::InputEncoding` defaults to OEM code page (`ibm437`) when `pwsh.exe` is spawned with redirected stdio on Windows, causing `ReadLine()` to block forever on UTF-8 JSON — fixed by explicitly setting UTF-8 encoding and replacing `Console.Out` with an auto-flushing `StreamWriter` via `[Console]::SetOut()`; (3) `Register-IshRemoteMcpTool` emitted an invalid `type: "object"` field in `ToolAnnotations` and used string `"true"`/`"false"` instead of boolean `$true`/`$false` for hint values, causing strict MCP schema validation to reject the tools list. Server name updated from `"PowerShell MCP Server (Template)"` to `"ISHRemote MCP Server"` and version bumped to `0.3.0`. Also fixed the server looping forever on stdin EOF (orphaned `pwsh` processes) by breaking the while loop when `ReadLine()` returns `$null`. See #243 and #261. Thanks @ddemeyer * Migrated all 58 `*.Tests.ps1` files from Pester v5 to Pester v6 (`Should -Be` to `Should-Be`, `Should -BeExactly` to `Should-BeString -CaseSensitive`, `Should -Not -BeNullOrEmpty` to `Should-NotBeNull`, `Should -Throw "msg"` to `Should-Throw -ExceptionMessage "msg"`, etc.). CI install gates updated to `-MinimumVersion 6.0.0`. Classic `Should -Not -Throw` retained as there is no `Should-NotThrow` equivalent in Pester 6. Hardened the library for parallel test execution by replacing the process-wide `TrisoftCmdletLogger` singleton with per-cmdlet `ILogger` routing and adding a double-checked lock on `IshSession._ishTypeFieldSetup` to eliminate Collection was modified races under `Run.Parallel = $true`. CI Pester invocations now use `New-PesterConfiguration` (with `Run.Parallel = $false`) so parallel mode can be toggled in one place when ready. See #242, #265, #266. * Fixed `New-IshSession` (protocol `WcfSoapWithOpenIdConnect`, PowerShell 7.2+/.NET 6.0+) throwing `FileLoadException: Could not load file or assembly 'Microsoft.IdentityModel.Tokens, Version=8.14.0.0, ...'. The located assembly's manifest definition does not match the assembly reference.` on machines where a different build of `Microsoft.IdentityModel.Tokens` (and related `Duende.IdentityModel.OidcClient`) is registered in the Global Assembly Cache (GAC) — observed on machines with Microsoft Intune Management Extension installed. `AppDomainModuleAssemblyInitializer` now force-loads ISHRemote's own bundled copies of `Duende.IdentityModel`, `Duende.IdentityModel.OidcClient`, `Microsoft.IdentityModel.Abstractions/.Logging/.Tokens/.Tokens.Saml/.Xml` as early as possible during module import, and `SessionCmdlet.BeginProcessing` now reports the full forced list over `-Verbose`. Root cause for the `Duende.IdentityModel.OidcClient` variant: `InfoShareOpenIdConnectSystemBrowser` was `public` and implemented `Duende.IdentityModel.OidcClient.Browser.IBrowser`, which put it in `Trisoft.ISHRemote.dll`'s exported types, forcing PowerShell's own binary-module cmdlet discovery (`Assembly.GetExportedTypes()`) to resolve `Duende.IdentityModel.OidcClient` before `IModuleAssemblyInitializer.OnImport()` ever ran — see Breaking Changes - Code. A new `TestPrerequisite.Tests.ps1` check asserts no assembly is ever loaded from the GAC on PowerShell Core. See #272. Thanks @ddemeyer +* Fixed cmdlets over protocol `WcfSoapWithOpenIdConnect` occasionally throwing `An unsecured or incorrectly secured fault was received from the other party` on the first SOAP call after a channel fault, requiring the user to re-run the same cmdlet for it to succeed (the existing #201/#219 rebuild-on-next-call logic only kicked in on a second, separate call). Each `Get*25Channel()` method in `InfoShareWcfSoapWithOpenIdConnectConnection` now returns the channel wrapped in a new `RetryOnFaultProxy` (`System.Reflection.DispatchProxy`) that catches `CommunicationException`/`FaultException` on the actual SOAP call, rebuilds the channel via the existing rebuild logic, and retries exactly once within the same cmdlet invocation before propagating any further failure — with the original exception type/stack trace preserved. Requires a new `net48`-only NuGet dependency, `System.Reflection.DispatchProxy` (built into the BCL on `net6.0`/`net10.0`). See #273. Thanks @ddemeyer diff --git a/Source/ISHRemote/Trisoft.ISHRemote/Connection/InfoShareWcfSoapWithOpenIdConnectConnection.cs b/Source/ISHRemote/Trisoft.ISHRemote/Connection/InfoShareWcfSoapWithOpenIdConnectConnection.cs index 731fee5..2097191 100644 --- a/Source/ISHRemote/Trisoft.ISHRemote/Connection/InfoShareWcfSoapWithOpenIdConnectConnection.cs +++ b/Source/ISHRemote/Trisoft.ISHRemote/Connection/InfoShareWcfSoapWithOpenIdConnectConnection.cs @@ -532,6 +532,15 @@ private void BackgroundTaskClientChannelFaulted(object sender, EventArgs e) /// /// The proxy public Annotation25ServiceReference.Annotation GetAnnotation25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureAnnotation25Channel(), EnsureAnnotation25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private Annotation25ServiceReference.Annotation EnsureAnnotation25Channel() { var clientInnerChannelState = _annotationClient?.InnerChannel?.State; var serviceReferenceState = (_annotationServiceReference as ICommunicationObject)?.State; @@ -592,6 +601,15 @@ public Annotation25ServiceReference.Annotation GetAnnotation25Channel() /// /// The proxy public Application25ServiceReference.Application GetApplication25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureApplication25Channel(), EnsureApplication25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private Application25ServiceReference.Application EnsureApplication25Channel() { var clientInnerChannelState = _applicationClient?.InnerChannel?.State; var serviceReferenceState = (_applicationServiceReference as ICommunicationObject)?.State; @@ -653,6 +671,15 @@ public Application25ServiceReference.Application GetApplication25Channel() /// /// The proxy public DocumentObj25ServiceReference.DocumentObj GetDocumentObj25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureDocumentObj25Channel(), EnsureDocumentObj25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private DocumentObj25ServiceReference.DocumentObj EnsureDocumentObj25Channel() { var clientInnerChannelState = _documentObjClient?.InnerChannel?.State; var serviceReferenceState = (_documentObjServiceReference as ICommunicationObject)?.State; @@ -714,6 +741,15 @@ public DocumentObj25ServiceReference.DocumentObj GetDocumentObj25Channel() /// /// The proxy public Folder25ServiceReference.Folder GetFolder25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureFolder25Channel(), EnsureFolder25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private Folder25ServiceReference.Folder EnsureFolder25Channel() { var clientInnerChannelState = _folderClient?.InnerChannel?.State; var serviceReferenceState = (_folderServiceReference as ICommunicationObject)?.State; @@ -775,6 +811,15 @@ public Folder25ServiceReference.Folder GetFolder25Channel() /// /// The proxy public User25ServiceReference.User GetUser25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureUser25Channel(), EnsureUser25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private User25ServiceReference.User EnsureUser25Channel() { var clientInnerChannelState = _userClient?.InnerChannel?.State; var serviceReferenceState = (_userServiceReference as ICommunicationObject)?.State; @@ -836,6 +881,15 @@ public User25ServiceReference.User GetUser25Channel() /// /// The proxy public UserRole25ServiceReference.UserRole GetUserRole25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureUserRole25Channel(), EnsureUserRole25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private UserRole25ServiceReference.UserRole EnsureUserRole25Channel() { var clientInnerChannelState = _userRoleClient?.InnerChannel?.State; var serviceReferenceState = (_userRoleServiceReference as ICommunicationObject)?.State; @@ -897,6 +951,15 @@ public UserRole25ServiceReference.UserRole GetUserRole25Channel() /// /// The proxy public UserGroup25ServiceReference.UserGroup GetUserGroup25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureUserGroup25Channel(), EnsureUserGroup25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private UserGroup25ServiceReference.UserGroup EnsureUserGroup25Channel() { var clientInnerChannelState = _userGroupClient?.InnerChannel?.State; var serviceReferenceState = (_userGroupServiceReference as ICommunicationObject)?.State; @@ -958,6 +1021,15 @@ public UserGroup25ServiceReference.UserGroup GetUserGroup25Channel() /// /// The proxy public ListOfValues25ServiceReference.ListOfValues GetListOfValues25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureListOfValues25Channel(), EnsureListOfValues25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private ListOfValues25ServiceReference.ListOfValues EnsureListOfValues25Channel() { var clientInnerChannelState = _listOfValuesClient?.InnerChannel?.State; var serviceReferenceState = (_listOfValuesServiceReference as ICommunicationObject)?.State; @@ -1019,6 +1091,15 @@ public ListOfValues25ServiceReference.ListOfValues GetListOfValues25Channel() /// /// The proxy public PublicationOutput25ServiceReference.PublicationOutput GetPublicationOutput25Channel() + { + return RetryOnFaultProxy.Wrap(EnsurePublicationOutput25Channel(), EnsurePublicationOutput25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private PublicationOutput25ServiceReference.PublicationOutput EnsurePublicationOutput25Channel() { var clientInnerChannelState = _publicationOutputClient?.InnerChannel?.State; var serviceReferenceState = (_publicationOutputServiceReference as ICommunicationObject)?.State; @@ -1080,6 +1161,15 @@ public PublicationOutput25ServiceReference.PublicationOutput GetPublicationOutpu /// /// The proxy public OutputFormat25ServiceReference.OutputFormat GetOutputFormat25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureOutputFormat25Channel(), EnsureOutputFormat25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private OutputFormat25ServiceReference.OutputFormat EnsureOutputFormat25Channel() { var clientInnerChannelState = _outputFormatClient?.InnerChannel?.State; var serviceReferenceState = (_outputFormatServiceReference as ICommunicationObject)?.State; @@ -1141,6 +1231,15 @@ public OutputFormat25ServiceReference.OutputFormat GetOutputFormat25Channel() /// /// The proxy public Settings25ServiceReference.Settings GetSettings25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureSettings25Channel(), EnsureSettings25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private Settings25ServiceReference.Settings EnsureSettings25Channel() { var clientInnerChannelState = _settingsClient?.InnerChannel?.State; var serviceReferenceState = (_settingsServiceReference as ICommunicationObject)?.State; @@ -1198,10 +1297,19 @@ public Settings25ServiceReference.Settings GetSettings25Channel() } /// - /// Create a /Wcf/API25/Edt.svc proxy + /// Create a /Wcf/API25/EDT.svc proxy /// /// The proxy public EDT25ServiceReference.EDT GetEDT25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureEDT25Channel(), EnsureEDT25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private EDT25ServiceReference.EDT EnsureEDT25Channel() { var clientInnerChannelState = _EDTClient?.InnerChannel?.State; var serviceReferenceState = (_EDTServiceReference as ICommunicationObject)?.State; @@ -1263,6 +1371,15 @@ public EDT25ServiceReference.EDT GetEDT25Channel() /// /// The proxy public EventMonitor25ServiceReference.EventMonitor GetEventMonitor25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureEventMonitor25Channel(), EnsureEventMonitor25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private EventMonitor25ServiceReference.EventMonitor EnsureEventMonitor25Channel() { var clientInnerChannelState = _eventMonitorClient?.InnerChannel?.State; var serviceReferenceState = (_eventMonitorServiceReference as ICommunicationObject)?.State; @@ -1324,6 +1441,15 @@ public EventMonitor25ServiceReference.EventMonitor GetEventMonitor25Channel() /// /// The proxy public Baseline25ServiceReference.Baseline GetBaseline25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureBaseline25Channel(), EnsureBaseline25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private Baseline25ServiceReference.Baseline EnsureBaseline25Channel() { var clientInnerChannelState = _baselineClient?.InnerChannel?.State; var serviceReferenceState = (_baselineServiceReference as ICommunicationObject)?.State; @@ -1385,6 +1511,15 @@ public Baseline25ServiceReference.Baseline GetBaseline25Channel() /// /// The proxy public MetadataBinding25ServiceReference.MetadataBinding GetMetadataBinding25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureMetadataBinding25Channel(), EnsureMetadataBinding25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private MetadataBinding25ServiceReference.MetadataBinding EnsureMetadataBinding25Channel() { var clientInnerChannelState = _metadataBindingClient?.InnerChannel?.State; var serviceReferenceState = (_metadataBindingServiceReference as ICommunicationObject)?.State; @@ -1446,6 +1581,15 @@ public MetadataBinding25ServiceReference.MetadataBinding GetMetadataBinding25Cha /// /// The proxy public Search25ServiceReference.Search GetSearch25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureSearch25Channel(), EnsureSearch25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private Search25ServiceReference.Search EnsureSearch25Channel() { var clientInnerChannelState = _searchClient?.InnerChannel?.State; var serviceReferenceState = (_searchServiceReference as ICommunicationObject)?.State; @@ -1507,6 +1651,15 @@ public Search25ServiceReference.Search GetSearch25Channel() /// /// The proxy public TranslationJob25ServiceReference.TranslationJob GetTranslationJob25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureTranslationJob25Channel(), EnsureTranslationJob25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private TranslationJob25ServiceReference.TranslationJob EnsureTranslationJob25Channel() { var clientInnerChannelState = _translationJobClient?.InnerChannel?.State; var serviceReferenceState = (_translationJobServiceReference as ICommunicationObject)?.State; @@ -1568,6 +1721,15 @@ public TranslationJob25ServiceReference.TranslationJob GetTranslationJob25Channe /// /// The proxy public TranslationTemplate25ServiceReference.TranslationTemplate GetTranslationTemplate25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureTranslationTemplate25Channel(), EnsureTranslationTemplate25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private TranslationTemplate25ServiceReference.TranslationTemplate EnsureTranslationTemplate25Channel() { var clientInnerChannelState = _translationTemplateClient?.InnerChannel?.State; var serviceReferenceState = (_translationTemplateServiceReference as ICommunicationObject)?.State; @@ -1630,6 +1792,15 @@ public TranslationTemplate25ServiceReference.TranslationTemplate GetTranslationT /// /// The proxy public BackgroundTask25ServiceReference.BackgroundTask GetBackgroundTask25Channel() + { + return RetryOnFaultProxy.Wrap(EnsureBackgroundTask25Channel(), EnsureBackgroundTask25Channel); + } + /// + /// Ensures a live (non-faulted) channel, rebuilding it if necessary, and returns the raw (unwrapped) channel - + /// used both as the public accessor's initial target and as the rebuild delegate for retries in . + /// + /// The raw proxy, never wrapped. + private BackgroundTask25ServiceReference.BackgroundTask EnsureBackgroundTask25Channel() { var clientInnerChannelState = _backgroundTaskClient?.InnerChannel?.State; var serviceReferenceState = (_backgroundTaskServiceReference as ICommunicationObject)?.State; diff --git a/Source/ISHRemote/Trisoft.ISHRemote/Connection/RetryOnFaultProxy.cs b/Source/ISHRemote/Trisoft.ISHRemote/Connection/RetryOnFaultProxy.cs new file mode 100644 index 0000000..66b8767 --- /dev/null +++ b/Source/ISHRemote/Trisoft.ISHRemote/Connection/RetryOnFaultProxy.cs @@ -0,0 +1,106 @@ +/* +* Copyright (c) 2014 All Rights Reserved by the SDL Group. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.ServiceModel; + +namespace Trisoft.ISHRemote.Connection +{ + /// + /// Transparent single-retry wrapper around a WCF SOAP channel interface (e.g. Application25ServiceReference.Application). + /// Used by so that a channel that faults with + /// "An unsecured or incorrectly secured fault was received from the other party" self-heals within the same cmdlet + /// invocation, instead of requiring the caller to retry the cmdlet itself. See https://github.com/RWS/ISHRemote/issues/273. + /// + /// Only wraps the plain service-contract interface, not ICommunicationObject/IDisposable - the channel + /// state checks, Faulted event wiring and Dispose() bookkeeping in InfoShareWcfSoapWithOpenIdConnectConnection + /// keep operating on the underlying raw channel field directly, this proxy is only applied at the point where the + /// channel is handed out to callers. + /// + /// Each public Get*25Channel() method is split into itself (which wraps exactly once) and a private + /// Ensure*25Channel() helper (which does the state-check/rebuild and returns the raw, unwrapped channel). The + /// rebuild delegate passed to must be that private helper - see the warning on . + /// + /// The generated WCF service-contract interface, for example Baseline25ServiceReference.Baseline. + /// + /// Must be public, not internal: on .NET Framework, + /// generates the proxy type in a new dynamic assembly under strict Reflection.Emit visibility checks, so a + /// non-public TProxy fails to load with "Access is denied" (observed on Windows PowerShell 5.1/net48). + /// .NET 6.0+ relaxes this check, so the same code works there regardless of visibility - keep it public so both + /// runtimes behave the same. + /// + public class RetryOnFaultProxy : DispatchProxy where T : class + { + private T _target; + private Func _rebuild; + + /// + /// Wraps so that any call throwing or + /// is retried exactly once against a freshly rebuilt channel obtained from + /// . + /// + /// Current channel instance, as currently held/rebuilt by the owning connection. + /// Delegate that returns a (possibly rebuilt) channel of type . This + /// MUST be the raw, unwrapped channel accessor (e.g. a private Ensure*25Channel() helper) - never the public + /// Get*25Channel() method that itself calls . Passing a rebuild delegate that returns + /// another wrapped proxy causes each retry to recurse into a brand-new with its + /// own full retry budget; on a persistent (non-transient) fault this chains unbounded and causes a stack overflow + /// instead of a single bounded retry. + /// A proxy implementing that transparently retries once on fault. + public static T Wrap(T target, Func rebuild) + { + object proxy = Create>(); + var retryOnFaultProxy = (RetryOnFaultProxy)proxy; + retryOnFaultProxy._target = target; + retryOnFaultProxy._rebuild = rebuild; + return (T)proxy; + } + + /// + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + try + { + return targetMethod.Invoke(_target, args); + } + catch (TargetInvocationException tie) when (tie.InnerException is CommunicationException || tie.InnerException is FaultException) + { + // The current channel just faulted; Get*25Channel() rebuild logic will detect the Faulted state and + // hand back a fresh channel. Retry exactly once, then let any further failure propagate as-is. + _target = _rebuild(); + try + { + return targetMethod.Invoke(_target, args); + } + catch (TargetInvocationException retryTie) + { + // Unwrap so callers keep seeing the original exception type (e.g. FaultException) instead of + // reflection's TargetInvocationException wrapper, preserving the original stack trace. + ExceptionDispatchInfo.Capture(retryTie.InnerException ?? retryTie).Throw(); + throw; // unreachable, ExceptionDispatchInfo.Throw() always throws + } + } + catch (TargetInvocationException tie) + { + // Non-transient failure (or unwrap of any other reflected exception) - unwrap for the same reason as above. + ExceptionDispatchInfo.Capture(tie.InnerException ?? tie).Throw(); + throw; // unreachable, ExceptionDispatchInfo.Throw() always throws + } + } + } +} diff --git a/Source/ISHRemote/Trisoft.ISHRemote/Connection/__ConnectionClassDiagram.cd b/Source/ISHRemote/Trisoft.ISHRemote/Connection/__ConnectionClassDiagram.cd index bbd032d..b76cef1 100644 --- a/Source/ISHRemote/Trisoft.ISHRemote/Connection/__ConnectionClassDiagram.cd +++ b/Source/ISHRemote/Trisoft.ISHRemote/Connection/__ConnectionClassDiagram.cd @@ -156,5 +156,12 @@ + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + Connection\RetryOnFaultProxy.cs + + \ No newline at end of file diff --git a/Source/ISHRemote/Trisoft.ISHRemote/Trisoft.ISHRemote.csproj b/Source/ISHRemote/Trisoft.ISHRemote/Trisoft.ISHRemote.csproj index 8e95a85..fd513d1 100644 --- a/Source/ISHRemote/Trisoft.ISHRemote/Trisoft.ISHRemote.csproj +++ b/Source/ISHRemote/Trisoft.ISHRemote/Trisoft.ISHRemote.csproj @@ -32,6 +32,7 @@ +