diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPIntuneReportExportRows.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPIntuneReportExportRows.ps1 index 7c0815b05b..30e3283e66 100644 --- a/backend/Modules/CIPPCore/Public/Get-CIPPIntuneReportExportRows.ps1 +++ b/backend/Modules/CIPPCore/Public/Get-CIPPIntuneReportExportRows.ps1 @@ -18,6 +18,8 @@ function Get-CIPPIntuneReportExportRows { $Client = [System.Net.Http.HttpClient]::new() $Client.Timeout = [TimeSpan]::FromMinutes(30) + # Disposing the client aborts a stalled download when Craft stops the pipeline (PS 7.6+). + $OnStop = $PSCmdlet.PipelineStopToken?.Register([Action]$Client.Dispose) try { $Download = $Client.GetStreamAsync($Url).GetAwaiter().GetResult() $Archive = [System.IO.Compression.ZipArchive]::new($Download, [System.IO.Compression.ZipArchiveMode]::Read) @@ -45,5 +47,8 @@ function Get-CIPPIntuneReportExportRows { } } finally { $Reader.Close() } } finally { $Archive.Dispose() } - } finally { $Client.Dispose() } + } finally { + if ($OnStop) { $OnStop.Dispose() } + $Client.Dispose() + } } diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPSPOSiteBulk.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPSPOSiteBulk.ps1 index dfc0db5d67..3b96a036ea 100644 --- a/backend/Modules/CIPPCore/Public/Get-CIPPSPOSiteBulk.ps1 +++ b/backend/Modules/CIPPCore/Public/Get-CIPPSPOSiteBulk.ps1 @@ -109,7 +109,7 @@ function Get-CIPPSPOSiteBulk { $BatchUrls.Add($Chunk) } - $Results = [CIPP.CIPPRestClient]::SendConcurrent($Requests, $MaxConcurrency, $MaxRetries) + $Results = [CIPP.CIPPRestClient]::SendConcurrent($Requests, $MaxConcurrency, $MaxRetries, $PSCmdlet.PipelineStopToken) @(foreach ($Result in $Results) { $Chunk = $BatchUrls[$Result.Index] diff --git a/backend/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 b/backend/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 index 1f9d0ee40d..401ad78a85 100644 --- a/backend/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 +++ b/backend/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 @@ -24,6 +24,8 @@ function Get-DefenderTvmRaw { $Buffer = if ($Stream) { $null } else { [System.Collections.Generic.List[object]]::new() } $Client = [System.Net.Http.HttpClient]::new() $Client.Timeout = [TimeSpan]::FromMinutes(30) + # Disposing the client aborts a stalled download when Craft stops the pipeline (PS 7.6+). + $OnStop = $PSCmdlet.PipelineStopToken?.Register([Action]$Client.Dispose) try { foreach ($Url in $Files) { $NetStream = $Client.GetStreamAsync($Url).GetAwaiter().GetResult() @@ -40,6 +42,7 @@ function Get-DefenderTvmRaw { } } } finally { + if ($OnStop) { $OnStop.Dispose() } $Client.Dispose() } diff --git a/backend/Modules/CIPPCore/Public/GraphHelper/New-ExoBulkRequest.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/New-ExoBulkRequest.ps1 index 1dd62edf96..6b6110fb73 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/New-ExoBulkRequest.ps1 +++ b/backend/Modules/CIPPCore/Public/GraphHelper/New-ExoBulkRequest.ps1 @@ -137,7 +137,7 @@ function New-ExoBulkRequest { $ConcRequest.Headers = $ConcHeaders $ConcRequests.Add($ConcRequest) } - foreach ($ConcResult in [CIPP.CIPPRestClient]::SendConcurrent($ConcRequests, $Concurrency, 3)) { + foreach ($ConcResult in [CIPP.CIPPRestClient]::SendConcurrent($ConcRequests, $Concurrency, 3, $PSCmdlet.PipelineStopToken)) { $Parsed = $null if (-not $ConcResult.Error -and $ConcResult.IsSuccess) { try { $Parsed = [CIPP.CippJson]::ConvertFromResponse($ConcResult.Result) } catch { $Parsed = $null } diff --git a/backend/Modules/CIPPCore/Public/GraphHelper/New-GraphMultiTenantBulkRequest.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/New-GraphMultiTenantBulkRequest.ps1 index c9cfe60aaf..21e98e71f9 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/New-GraphMultiTenantBulkRequest.ps1 +++ b/backend/Modules/CIPPCore/Public/GraphHelper/New-GraphMultiTenantBulkRequest.ps1 @@ -58,7 +58,7 @@ function New-GraphMultiTenantBulkRequest { } if ($Requests.Count -gt 0) { - foreach ($Sent in [CIPP.CIPPRestClient]::SendConcurrent($Requests, $MaxConcurrency, $MaxRetries)) { + foreach ($Sent in [CIPP.CIPPRestClient]::SendConcurrent($Requests, $MaxConcurrency, $MaxRetries, $PSCmdlet.PipelineStopToken)) { $Owner = $Owners[$Sent.Index] if ($Sent.Error) { $Owner.Error = $Sent.Error diff --git a/backend/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 b/backend/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 index 852490652e..ba545432eb 100644 --- a/backend/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 +++ b/backend/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 @@ -174,7 +174,8 @@ function Invoke-CIPPRestMethod { $(if ($ContentType) { $ContentType } else { $null }), $true, $TimeoutSec, - $MaximumRedirection + $MaximumRedirection, + $PSCmdlet.PipelineStopToken ) } catch { # PowerShell wraps .NET static method exceptions in MethodInvocationException. diff --git a/backend/Modules/CIPPCore/Public/Set-CIPPSPOSiteBulk.ps1 b/backend/Modules/CIPPCore/Public/Set-CIPPSPOSiteBulk.ps1 index aeed055e42..571d207ae0 100644 --- a/backend/Modules/CIPPCore/Public/Set-CIPPSPOSiteBulk.ps1 +++ b/backend/Modules/CIPPCore/Public/Set-CIPPSPOSiteBulk.ps1 @@ -103,7 +103,7 @@ function Set-CIPPSPOSiteBulk { if (-not $PSCmdlet.ShouldProcess("$($Requests.Count) sites", 'Set Site Properties (bulk)')) { return @() } - $Results = [CIPP.CIPPRestClient]::SendConcurrent($Requests, $MaxConcurrency, $MaxRetries) + $Results = [CIPP.CIPPRestClient]::SendConcurrent($Requests, $MaxConcurrency, $MaxRetries, $PSCmdlet.PipelineStopToken) @(foreach ($Result in $Results) { $Site = $RequestSites[$Result.Index] diff --git a/backend/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneRequestBatch.ps1 b/backend/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneRequestBatch.ps1 index f287720b4e..4cdba61ec6 100644 --- a/backend/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneRequestBatch.ps1 +++ b/backend/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneRequestBatch.ps1 @@ -28,5 +28,5 @@ function Invoke-NinjaOneRequestBatch { $Item.Headers['Authorization'] = "Bearer $($Token.access_token)" $Item } - [CIPP.CIPPRestClient]::SendConcurrent([CIPP.CIPPConcurrentRequest[]]@($Batch), $Concurrency, $MaxRetries) + [CIPP.CIPPRestClient]::SendConcurrent([CIPP.CIPPConcurrentRequest[]]@($Batch), $Concurrency, $MaxRetries, $PSCmdlet.PipelineStopToken) } diff --git a/backend/Shared/CIPPSharp/CIPPRestClient.cs b/backend/Shared/CIPPSharp/CIPPRestClient.cs index 1d2e359742..f3c60b85a8 100644 --- a/backend/Shared/CIPPSharp/CIPPRestClient.cs +++ b/backend/Shared/CIPPSharp/CIPPRestClient.cs @@ -710,10 +710,11 @@ public static HttpResult Send( string? contentType = null, bool skipErrorCheck = false, int timeoutSec = 100, - int maximumRedirection = -1) + int maximumRedirection = -1, + CancellationToken? cancellationToken = null) { return SendAsync(uri, method, body, headers, contentType, - skipErrorCheck, timeoutSec, maximumRedirection) + skipErrorCheck, timeoutSec, maximumRedirection, cancellationToken ?? default) .GetAwaiter().GetResult(); } @@ -728,7 +729,8 @@ public static async Task SendAsync( string? contentType = null, bool skipErrorCheck = false, int timeoutSec = 100, - int maximumRedirection = -1) + int maximumRedirection = -1, + CancellationToken cancellationToken = default) { await EnsureClientsAsync().ConfigureAwait(false); @@ -743,7 +745,7 @@ public static async Task SendAsync( var rateWaited = TimeSpan.Zero; while (rateWait > TimeSpan.Zero && rateWaited < CIPPRateLimiter.MaxWait) { - await Task.Delay(rateWait).ConfigureAwait(false); + await Task.Delay(rateWait, cancellationToken).ConfigureAwait(false); rateWaited += rateWait; rateWait = CIPPRateLimiter.Recheck(rateClaim); } @@ -833,10 +835,9 @@ public static async Task SendAsync( // Instead we create a CancellationTokenSource per request with the // caller-specified timeout. timeoutSec = 0 means no timeout. // ---------------------------------------------------------- - using var cts = timeoutSec > 0 - ? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSec)) - : null; - var token = cts?.Token ?? CancellationToken.None; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (timeoutSec > 0) cts.CancelAfter(TimeSpan.FromSeconds(timeoutSec)); + var token = cts.Token; // ---------------------------------------------------------- // Send @@ -851,7 +852,11 @@ public static async Task SendAsync( // defensive body read below — masking the HTTP status the caller needs. response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); } - catch (OperationCanceledException) when (cts is not null && cts.IsCancellationRequested) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) { // Our per-request timeout CTS actually fired — this is a genuine // client-side timeout after timeoutSec. Let it propagate as an @@ -979,16 +984,18 @@ public static async Task SendAsync( public static CIPPConcurrentResult[] SendConcurrent( IEnumerable requests, int maxConcurrency = 8, - int maxRetries = 3) + int maxRetries = 3, + CancellationToken? cancellationToken = null) { - return SendConcurrentAsync(requests, maxConcurrency, maxRetries) + return SendConcurrentAsync(requests, maxConcurrency, maxRetries, cancellationToken ?? default) .GetAwaiter().GetResult(); } public static async Task SendConcurrentAsync( IEnumerable requests, int maxConcurrency = 8, - int maxRetries = 3) + int maxRetries = 3, + CancellationToken cancellationToken = default) { var list = requests is null ? new List() : requests.ToList(); if (list.Count == 0) return Array.Empty(); @@ -1003,8 +1010,8 @@ public static async Task SendConcurrentAsync( var req = list[i]; tasks[i] = Task.Run(async () => { - await gate.WaitAsync().ConfigureAwait(false); - try { return await SendOneWithRetryAsync(index, req, maxRetries).ConfigureAwait(false); } + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try { return await SendOneWithRetryAsync(index, req, maxRetries, cancellationToken).ConfigureAwait(false); } finally { gate.Release(); } }); } @@ -1012,7 +1019,7 @@ public static async Task SendConcurrentAsync( } private static async Task SendOneWithRetryAsync( - int index, CIPPConcurrentRequest req, int maxRetries) + int index, CIPPConcurrentRequest req, int maxRetries, CancellationToken cancellationToken) { var attempt = 0; while (true) @@ -1025,12 +1032,13 @@ private static async Task SendOneWithRetryAsync( var res = await SendAsync(req.Uri, req.Method, req.Body, req.Headers, req.ContentType, skipErrorCheck: true, timeoutSec: req.TimeoutSec, - maximumRedirection: req.MaximumRedirection).ConfigureAwait(false); + maximumRedirection: req.MaximumRedirection, + cancellationToken: cancellationToken).ConfigureAwait(false); var retryable = res.StatusCode == 429 || (res.StatusCode >= 500 && res.StatusCode <= 599); if (retryable && attempt <= maxRetries) { - await Task.Delay(RetryDelay(res, attempt)).ConfigureAwait(false); + await Task.Delay(RetryDelay(res, attempt), cancellationToken).ConfigureAwait(false); continue; } return new CIPPConcurrentResult @@ -1039,11 +1047,11 @@ private static async Task SendOneWithRetryAsync( Result = res, Attempts = attempt, }; } - catch (Exception ex) + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) { if (attempt <= maxRetries) { - await Task.Delay(RetryDelay(null, attempt)).ConfigureAwait(false); + await Task.Delay(RetryDelay(null, attempt), cancellationToken).ConfigureAwait(false); continue; } return new CIPPConcurrentResult diff --git a/backend/Shared/CIPPSharp/bin/CIPPSharp.dll b/backend/Shared/CIPPSharp/bin/CIPPSharp.dll index 0f23b485f6..5cd2470c47 100644 Binary files a/backend/Shared/CIPPSharp/bin/CIPPSharp.dll and b/backend/Shared/CIPPSharp/bin/CIPPSharp.dll differ diff --git a/backend/Tests/Build/CIPPRestClient.Cancellation.Tests.ps1 b/backend/Tests/Build/CIPPRestClient.Cancellation.Tests.ps1 new file mode 100644 index 0000000000..0374735fb0 --- /dev/null +++ b/backend/Tests/Build/CIPPRestClient.Cancellation.Tests.ps1 @@ -0,0 +1,50 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + Add-Type -Path (Join-Path $RepoRoot 'Shared/CIPPSharp/bin/CIPPSharp.dll') + + # Accepts connections and never answers, so every request blocks until it is cancelled. + $script:Listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $script:Listener.Start() + $script:Uri = "http://127.0.0.1:$($script:Listener.LocalEndpoint.Port)/hang" + $script:Server = [powershell]::Create().AddScript({ + param($Listener) + $Held = [System.Collections.Generic.List[object]]::new() + while ($true) { + try { $Held.Add($Listener.AcceptTcpClient()) } catch { break } + } + }).AddArgument($script:Listener) + $null = $script:Server.BeginInvoke() + + function Get-SendError([scriptblock]$Call) { + $Watch = [System.Diagnostics.Stopwatch]::StartNew() + try { & $Call; $Err = $null } catch { $Err = $_.Exception.InnerException ?? $_.Exception } + [pscustomobject]@{ Error = $Err; Ms = $Watch.ElapsedMilliseconds } + } +} + +AfterAll { + $script:Listener.Stop() + $script:Server.Dispose() +} + +Describe 'CIPPRestClient cancellation' { + It 'Send stops on the caller token as a cancellation, not a server-cancel transport error' { + $Cts = [System.Threading.CancellationTokenSource]::new(300) + $Result = Get-SendError { [CIPP.CIPPRestClient]::Send($script:Uri, 'GET', $null, $null, $null, $true, 30, -1, $Cts.Token) } + $Result.Error | Should -BeOfType [System.OperationCanceledException] + $Result.Ms | Should -BeLessThan 10000 + } + + It 'Send still times out on timeoutSec when no token is passed' { + $Result = Get-SendError { [CIPP.CIPPRestClient]::Send($script:Uri, 'GET', $null, $null, $null, $true, 1, -1, $null) } + $Result.Error | Should -BeOfType [System.OperationCanceledException] + } + + It 'SendConcurrent stops on the caller token instead of retrying' { + $Requests = [CIPP.CIPPConcurrentRequest[]]@(1..3 | ForEach-Object { [CIPP.CIPPConcurrentRequest]@{ Uri = $script:Uri; TimeoutSec = 30 } }) + $Cts = [System.Threading.CancellationTokenSource]::new(300) + $Result = Get-SendError { [CIPP.CIPPRestClient]::SendConcurrent($Requests, 3, 3, $Cts.Token) } + $Result.Error | Should -BeOfType [System.OperationCanceledException] + $Result.Ms | Should -BeLessThan 10000 + } +} diff --git a/build/Dockerfile b/build/Dockerfile index 041008d1c9..79456003d0 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -13,7 +13,7 @@ ARG DOTNET_REGISTRY=ghcr.io/cyberdrain ARG CRAFT_BASE=ghcr.io/cyberdrain/craft:latest # ── Base: PowerShell SDK + ModuleBuilder (cached, rarely changes) ── -FROM ${DOTNET_REGISTRY}/dotnet/sdk:8.0-bookworm-slim AS ps-base +FROM ${DOTNET_REGISTRY}/dotnet/sdk:10.0-noble AS ps-base COPY build/Metadata/1.5.7 /usr/local/share/powershell/Modules/Metadata/1.5.7 COPY build/Configuration/1.6.0 /usr/local/share/powershell/Modules/Configuration/1.6.0 COPY build/ModuleBuilder/3.1.8 /usr/local/share/powershell/Modules/ModuleBuilder/3.1.8 @@ -69,7 +69,7 @@ RUN pwsh -File /build/build-single-module.ps1 -Module CIPPTests -KeepPublic # `func start` and is overwritten in the final image. This guarantees the DLL # in the container matches the SDK pinned by this stage, regardless of which # SDK the last committer happened to have installed. -FROM ${DOTNET_REGISTRY}/dotnet/sdk:8.0-bookworm-slim AS build-cippsharp +FROM ${DOTNET_REGISTRY}/dotnet/sdk:10.0-noble AS build-cippsharp WORKDIR /src COPY backend/Shared/CIPPSharp/CIPPSharp.csproj ./ RUN dotnet restore CIPPSharp.csproj diff --git a/build/Dockerfile.release b/build/Dockerfile.release index bb6e67353d..86f7154004 100644 --- a/build/Dockerfile.release +++ b/build/Dockerfile.release @@ -13,7 +13,7 @@ ARG DOTNET_REGISTRY=ghcr.io/cyberdrain ARG CRAFT_BASE=ghcr.io/cyberdrain/craft:latest # ── Base: PowerShell SDK + ModuleBuilder (cached, rarely changes) ── -FROM ${DOTNET_REGISTRY}/dotnet/sdk:8.0-bookworm-slim AS ps-base +FROM ${DOTNET_REGISTRY}/dotnet/sdk:10.0-noble AS ps-base COPY build/Metadata/1.5.7 /usr/local/share/powershell/Modules/Metadata/1.5.7 COPY build/Configuration/1.6.0 /usr/local/share/powershell/Modules/Configuration/1.6.0 COPY build/ModuleBuilder/3.1.8 /usr/local/share/powershell/Modules/ModuleBuilder/3.1.8 @@ -69,7 +69,7 @@ RUN pwsh -File /build/build-single-module.ps1 -Module CIPPTests -KeepPublic # `func start` and is overwritten in the final image. This guarantees the DLL # in the container matches the SDK pinned by this stage, regardless of which # SDK the last committer happened to have installed. -FROM ${DOTNET_REGISTRY}/dotnet/sdk:8.0-bookworm-slim AS build-cippsharp +FROM ${DOTNET_REGISTRY}/dotnet/sdk:10.0-noble AS build-cippsharp WORKDIR /src COPY backend/Shared/CIPPSharp/CIPPSharp.csproj ./ RUN dotnet restore CIPPSharp.csproj