Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -45,5 +47,8 @@ function Get-CIPPIntuneReportExportRows {
}
} finally { $Reader.Close() }
} finally { $Archive.Dispose() }
} finally { $Client.Dispose() }
} finally {
if ($OnStop) { $OnStop.Dispose() }
$Client.Dispose()
}
}
2 changes: 1 addition & 1 deletion backend/Modules/CIPPCore/Public/Get-CIPPSPOSiteBulk.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions backend/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -40,6 +42,7 @@ function Get-DefenderTvmRaw {
}
}
} finally {
if ($OnStop) { $OnStop.Dispose() }
$Client.Dispose()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion backend/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion backend/Modules/CIPPCore/Public/Set-CIPPSPOSiteBulk.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
46 changes: 27 additions & 19 deletions backend/Shared/CIPPSharp/CIPPRestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -728,7 +729,8 @@ public static async Task<HttpResult> SendAsync(
string? contentType = null,
bool skipErrorCheck = false,
int timeoutSec = 100,
int maximumRedirection = -1)
int maximumRedirection = -1,
CancellationToken cancellationToken = default)
{
await EnsureClientsAsync().ConfigureAwait(false);

Expand All @@ -743,7 +745,7 @@ public static async Task<HttpResult> 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);
}
Expand Down Expand Up @@ -833,10 +835,9 @@ public static async Task<HttpResult> 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
Expand All @@ -851,7 +852,11 @@ public static async Task<HttpResult> 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
Expand Down Expand Up @@ -979,16 +984,18 @@ public static async Task<HttpResult> SendAsync(
public static CIPPConcurrentResult[] SendConcurrent(
IEnumerable<CIPPConcurrentRequest> 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<CIPPConcurrentResult[]> SendConcurrentAsync(
IEnumerable<CIPPConcurrentRequest> requests,
int maxConcurrency = 8,
int maxRetries = 3)
int maxRetries = 3,
CancellationToken cancellationToken = default)
{
var list = requests is null ? new List<CIPPConcurrentRequest>() : requests.ToList();
if (list.Count == 0) return Array.Empty<CIPPConcurrentResult>();
Expand All @@ -1003,16 +1010,16 @@ public static async Task<CIPPConcurrentResult[]> 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(); }
});
}
return await Task.WhenAll(tasks).ConfigureAwait(false);
}

private static async Task<CIPPConcurrentResult> SendOneWithRetryAsync(
int index, CIPPConcurrentRequest req, int maxRetries)
int index, CIPPConcurrentRequest req, int maxRetries, CancellationToken cancellationToken)
{
var attempt = 0;
while (true)
Expand All @@ -1025,12 +1032,13 @@ private static async Task<CIPPConcurrentResult> 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
Expand All @@ -1039,11 +1047,11 @@ private static async Task<CIPPConcurrentResult> 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
Expand Down
Binary file modified backend/Shared/CIPPSharp/bin/CIPPSharp.dll
Binary file not shown.
50 changes: 50 additions & 0 deletions backend/Tests/Build/CIPPRestClient.Cancellation.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 2 additions & 2 deletions build/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions build/Dockerfile.release
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading