Skip to content

Commit 58a8eab

Browse files
nficanoclaude
andauthored
fix: resolve all open csharp-sdk bug, doc, and coverage issues (#34)
* fix: resolve all open csharp-sdk bug, doc, and coverage issues Bug fixes: - #22: register resume tokens server-side, restore prior session's EventLog on resume, return RESUME_WINDOW_EXPIRED for unknown/expired tokens. - #23: enforce max_runtime_sec via a watchdog linked to the run-token; emit job.error with FinalStatus="timed_out" and ErrorCode.Timeout on expiry. - #24: stop the lease watchdog as soon as the job reaches any terminal state so RunAsync no longer blocks on a sleeping watchdog until lease expiry. - #25: call BudgetLedger.AssertNotExhausted after charging a cost.* metric so the job terminates with BUDGET_EXHAUSTED (Retryable=false) on overspend. - #26: store an idempotency record including a SHA-256 payload fingerprint and creation time; mismatched payloads now raise DUPLICATE_KEY and entries expire after IdempotencyWindowSec (new ArcpServerOptions field, default 1h). - #27: buffer per-job events on the Job and replay them in order to a new subscriber that requested history:true, respecting from_event_seq; set JobSubscribedPayload.Replayed only when history was actually sent. - #30: client-side request bookkeeping (_pendingSubmits, _listJobsRequests, _subscriptions) is now cleaned up in catch blocks when send or wait-for-ack fails, so the next successful request isn't bound to a stale slot. Also fixes ArcpClient.DisposeAsync double-dispose throw. - #32: WebSocketTransport.ReceiveAsync uses a fast path that deserializes directly from the rented buffer when EndOfMessage is true on the first receive; multi-frame messages now stream into a pooled ArrayBufferWriter instead of allocating a new MemoryStream + ToArray() per message. Documentation: - #28: removed CS1591 from the global NoWarn list so missing XML documentation on public/protected members becomes a build error. Added ~700 doc summaries across the SDK so the build is clean with the warning enforced. Tests / coverage (#29): - Added 47 new tests across Arcp.UnitTests and Arcp.IntegrationTests. - Total tests: 60 -> 109. Line coverage union: 70.9% -> 80.7%. - New scripts/check-coverage.py merges cobertura reports and enforces a threshold; CI test job now runs the full solution with coverage and fails if line coverage drops below 80%. - CONTRIBUTING.md documents the local coverage workflow. Note: #31 was already addressed on main in 1f2d627. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: replace malformed XML doc summary for generic ToJsonElement<T> The auto-generated stub used `<t>` (Roslyn read it as an unclosed XML element) inside the summary, which surfaces as CS1570 in Release builds and breaks both the test job and the CodeQL autobuilder. Replace with a hand-written summary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1f2d627 commit 58a8eab

104 files changed

Lines changed: 2326 additions & 90 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,21 @@ jobs:
7373
- name: Build
7474
run: dotnet build ARCP.slnx --configuration Release --no-restore
7575

76-
- name: Test
76+
- name: Test (all test projects, with coverage)
7777
run: >
78-
dotnet test tests/Arcp.UnitTests/Arcp.UnitTests.csproj
78+
dotnet test ARCP.slnx
7979
--configuration Release
8080
--no-build
8181
--verbosity normal
82-
--logger "trx;LogFileName=unit-tests.trx"
82+
--collect:"XPlat Code Coverage"
83+
--logger "trx;LogFileName=tests.trx"
8384
--logger "console;verbosity=detailed"
8485
--results-directory ${{ github.workspace }}/TestResults
8586
87+
- name: Coverage gate (line coverage >= 80%)
88+
run: |
89+
python3 scripts/check-coverage.py --threshold 80 --results-dir "${{ github.workspace }}/TestResults"
90+
8691
- name: Upload test results
8792
if: failure()
8893
uses: actions/upload-artifact@v7

CONTRIBUTING.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,22 @@ Two layers must pass before a PR merges:
9494
CI runs both on every PR. A PR that changes which feature flags the SDK
9595
negotiates must also update the README feature matrix in the same change.
9696

97+
### Code coverage
98+
99+
Coverage is collected with Coverlet via the standard `--collect:"XPlat Code Coverage"`
100+
switch and merged across test projects. Reproduce the CI number locally with:
101+
102+
```sh
103+
rm -rf TestResults
104+
dotnet test ARCP.slnx --collect:"XPlat Code Coverage" --results-directory TestResults
105+
python3 scripts/check-coverage.py --threshold 80 --results-dir TestResults
106+
```
107+
108+
The CI gate enforces a minimum **80% line coverage** union across all four test
109+
projects. If `check-coverage.py` reports a number below the threshold, add tests
110+
before opening the PR — the script also prints the union percentage so you can
111+
see how much headroom there is.
112+
97113
## Coding standards
98114

99115
Formatting, analyzers, and style are enforced as part of the build:

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<AnalysisMode>Default</AnalysisMode>
1111
<DeterministicSourcePaths>true</DeterministicSourcePaths>
1212
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
13-
<NoWarn>$(NoWarn);CA1014;CA1024;CA1031;CA1032;CA1034;CA1051;CA1062;CA1063;CA1064;CA1303;CA1305;CA1307;CA1308;CA1310;CA1311;CA1508;CA1707;CA1715;CA1716;CA1720;CA1721;CA1812;CA1815;CA1816;CA1822;CA1848;CA1849;CA1851;CA1852;CA1859;CA1860;CA1865;CA1866;CA1869;CA1872;CA1873;CA2007;CA2008;CA2227;CA2234;CA2237;CS1591;CS8618;CS8625;CS8629;CS8765;SYSLIB0050;NU1701;RS0026;RS0027;SA0001;SA1000;SA1101;SA1118;SA1124;SA1128;SA1129;SA1200;SA1201;SA1202;SA1203;SA1208;SA1210;SA1214;SA1300;SA1303;SA1309;SA1311;SA1313;SA1316;SA1401;SA1402;SA1413;SA1500;SA1501;SA1502;SA1503;SA1504;SA1505;SA1507;SA1508;SA1512;SA1513;SA1514;SA1515;SA1516;SA1517;SA1518;SA1600;SA1601;SA1602;SA1604;SA1611;SA1612;SA1614;SA1615;SA1616;SA1618;SA1619;SA1623;SA1625;SA1626;SA1629;SA1633;SA1642;SA1649;SA1652</NoWarn>
13+
<NoWarn>$(NoWarn);CA1014;CA1024;CA1031;CA1032;CA1034;CA1051;CA1062;CA1063;CA1064;CA1303;CA1305;CA1307;CA1308;CA1310;CA1311;CA1508;CA1707;CA1715;CA1716;CA1720;CA1721;CA1812;CA1815;CA1816;CA1822;CA1848;CA1849;CA1851;CA1852;CA1859;CA1860;CA1865;CA1866;CA1869;CA1872;CA1873;CA2007;CA2008;CA2227;CA2234;CA2237;CS8618;CS8625;CS8629;CS8765;SYSLIB0050;NU1701;RS0026;RS0027;SA0001;SA1000;SA1101;SA1118;SA1124;SA1128;SA1129;SA1200;SA1201;SA1202;SA1203;SA1208;SA1210;SA1214;SA1300;SA1303;SA1309;SA1311;SA1313;SA1316;SA1401;SA1402;SA1413;SA1500;SA1501;SA1502;SA1503;SA1504;SA1505;SA1507;SA1508;SA1512;SA1513;SA1514;SA1515;SA1516;SA1517;SA1518;SA1600;SA1601;SA1602;SA1604;SA1611;SA1612;SA1614;SA1615;SA1616;SA1618;SA1619;SA1623;SA1625;SA1626;SA1629;SA1633;SA1642;SA1649;SA1652</NoWarn>
1414
</PropertyGroup>
1515

1616
<PropertyGroup Label="Documentation defaults (overridden by tests)">

scripts/check-coverage.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env python3
2+
"""Compute union line/branch coverage from cobertura reports and fail if line coverage
3+
is below the requested threshold. Run after `dotnet test ... --collect:"XPlat Code Coverage"`.
4+
5+
Usage:
6+
python3 scripts/check-coverage.py --threshold 80 --results-dir TestResults
7+
"""
8+
import argparse
9+
import sys
10+
import xml.etree.ElementTree as ET
11+
from collections import defaultdict
12+
from pathlib import Path
13+
14+
15+
def main() -> int:
16+
ap = argparse.ArgumentParser()
17+
ap.add_argument("--threshold", type=float, default=80.0, help="Minimum line-coverage percent.")
18+
ap.add_argument("--results-dir", type=Path, default=Path("TestResults"))
19+
args = ap.parse_args()
20+
21+
covered_lines: dict[str, set[int]] = defaultdict(set)
22+
valid_lines: dict[str, set[int]] = defaultdict(set)
23+
covered_branches: dict[str, set[tuple[int, int]]] = defaultdict(set)
24+
valid_branches: dict[str, set[tuple[int, int]]] = defaultdict(set)
25+
26+
reports = list(args.results_dir.rglob("coverage.cobertura.xml"))
27+
if not reports:
28+
print(f"No coverage.cobertura.xml found under {args.results_dir}", file=sys.stderr)
29+
return 1
30+
for cobertura in reports:
31+
tree = ET.parse(cobertura)
32+
for cls in tree.iter("class"):
33+
fname = cls.get("filename", "")
34+
for line in cls.iter("line"):
35+
ln = int(line.get("number", "0"))
36+
hits = int(line.get("hits", "0"))
37+
valid_lines[fname].add(ln)
38+
if hits > 0:
39+
covered_lines[fname].add(ln)
40+
if line.get("branch", "false") == "true":
41+
cc = line.get("condition-coverage", "")
42+
if "(" in cc and "/" in cc:
43+
seg = cc[cc.index("(") + 1:cc.index(")")]
44+
try:
45+
cov, tot = (int(x) for x in seg.split("/"))
46+
except ValueError:
47+
continue
48+
for i in range(tot):
49+
valid_branches[fname].add((ln, i))
50+
for i in range(cov):
51+
covered_branches[fname].add((ln, i))
52+
53+
total_valid = sum(len(s) for s in valid_lines.values())
54+
total_covered = sum(len(covered_lines[f] & valid_lines[f]) for f in valid_lines)
55+
bvalid = sum(len(s) for s in valid_branches.values())
56+
bcov = sum(len(covered_branches[f] & valid_branches[f]) for f in valid_branches)
57+
line_pct = (100 * total_covered / total_valid) if total_valid else 0.0
58+
branch_pct = (100 * bcov / bvalid) if bvalid else 0.0
59+
60+
print(f"Reports merged: {len(reports)}")
61+
print(f"Line coverage: {total_covered}/{total_valid} = {line_pct:.2f}%")
62+
print(f"Branch coverage: {bcov}/{bvalid} = {branch_pct:.2f}%")
63+
print(f"Threshold: {args.threshold:.2f}% (line)")
64+
65+
if line_pct + 1e-9 < args.threshold:
66+
print(f"FAIL: line coverage {line_pct:.2f}% is below threshold {args.threshold:.2f}%", file=sys.stderr)
67+
return 1
68+
return 0
69+
70+
71+
if __name__ == "__main__":
72+
sys.exit(main())

src/Arcp.AspNetCore/ArcpEndpointRouteBuilderExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ namespace Arcp.AspNetCore;
1717
/// <summary>Options for <see cref="ArcpEndpointRouteBuilderExtensions.MapArcp"/>.</summary>
1818
public sealed class ArcpEndpointOptions
1919
{
20+
/// <summary>Request path the ARCP WebSocket endpoint is mounted at. Defaults to <c>/arcp</c>.</summary>
2021
public string Path { get; set; } = "/arcp";
2122

23+
/// <summary>Optional allow-list of <c>Host</c> headers. When set, requests with other host
24+
/// headers are rejected with 400 before any session work happens.</summary>
2225
public IReadOnlyList<string>? AllowedHosts { get; set; }
2326
}
2427

src/Arcp.Client/ArcpClient.Jobs.cs

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,31 +10,61 @@ namespace Arcp.Client;
1010

1111
public sealed partial class ArcpClient
1212
{
13+
/// <summary>Submit (asynchronous).</summary>
1314
public async Task<JobHandle> SubmitAsync(string agent, object? input = null, Lease? leaseRequest = null,
1415
LeaseConstraints? leaseConstraints = null, string? idempotencyKey = null,
1516
int? maxRuntimeSec = null, string? parentJobId = null, CancellationToken cancellationToken = default)
1617
{
1718
var handle = new JobHandle(this);
1819
_pendingSubmits.Enqueue(handle);
19-
await _transport.SendAsync(new Envelope
20+
var sent = false;
21+
try
2022
{
21-
Type = MessageTypeNames.JobSubmit,
22-
SessionId = SessionId.Value,
23-
Payload = new JobSubmitPayload
23+
await _transport.SendAsync(new Envelope
2424
{
25-
Agent = agent,
26-
Input = input is null ? null : ArcpJson.ToJsonElement(input),
27-
LeaseRequest = leaseRequest,
28-
LeaseConstraints = leaseConstraints,
29-
IdempotencyKey = idempotencyKey,
30-
MaxRuntimeSec = maxRuntimeSec,
31-
ParentJobId = parentJobId,
32-
},
33-
}, cancellationToken).ConfigureAwait(false);
34-
await handle.Accepted.WaitAsync(cancellationToken).ConfigureAwait(false);
35-
return handle;
25+
Type = MessageTypeNames.JobSubmit,
26+
SessionId = SessionId.Value,
27+
Payload = new JobSubmitPayload
28+
{
29+
Agent = agent,
30+
Input = input is null ? null : ArcpJson.ToJsonElement(input),
31+
LeaseRequest = leaseRequest,
32+
LeaseConstraints = leaseConstraints,
33+
IdempotencyKey = idempotencyKey,
34+
MaxRuntimeSec = maxRuntimeSec,
35+
ParentJobId = parentJobId,
36+
},
37+
}, cancellationToken).ConfigureAwait(false);
38+
sent = true;
39+
await handle.Accepted.WaitAsync(cancellationToken).ConfigureAwait(false);
40+
return handle;
41+
}
42+
catch
43+
{
44+
// The pending queue is FIFO-correlated with job.accepted responses. If we never
45+
// got an acceptance, evict our handle so the NEXT successful submit isn't bound
46+
// to this stale slot.
47+
RemovePendingSubmit(handle, sent);
48+
throw;
49+
}
3650
}
3751

52+
/// <summary>Evict a handle from the pending-submits queue. Walks the queue and re-enqueues
53+
/// every other handle in order so FIFO correlation with <c>job.accepted</c> is preserved.</summary>
54+
private void RemovePendingSubmit(JobHandle handle, bool ackPossiblyArrived)
55+
{
56+
// If we successfully sent and an acceptance may already be in flight, the dispatcher
57+
// could have removed our handle via TryDequeue already; either way, drain+filter.
58+
var keep = new System.Collections.Generic.List<JobHandle>();
59+
while (_pendingSubmits.TryDequeue(out var h))
60+
{
61+
if (!ReferenceEquals(h, handle)) keep.Add(h);
62+
}
63+
foreach (var h in keep) _pendingSubmits.Enqueue(h);
64+
_ = ackPossiblyArrived; // currently identical behavior; reserved for future correlation work
65+
}
66+
67+
/// <summary>Cancel job (asynchronous).</summary>
3868
public async Task CancelJobAsync(JobId jobId, string? reason = null, CancellationToken cancellationToken = default)
3969
{
4070
await _transport.SendAsync(new Envelope
@@ -46,19 +76,28 @@ await _transport.SendAsync(new Envelope
4676
}, cancellationToken).ConfigureAwait(false);
4777
}
4878

79+
/// <summary>List jobs (asynchronous).</summary>
4980
public async Task<SessionJobsPayload> ListJobsAsync(JobListFilter? filter = null, int? limit = null,
5081
string? cursor = null, CancellationToken cancellationToken = default)
5182
{
5283
var id = "msg_" + Ulid.NewUlid();
5384
var tcs = new TaskCompletionSource<SessionJobsPayload>(TaskCreationOptions.RunContinuationsAsynchronously);
5485
_listJobsRequests[id] = tcs;
55-
await _transport.SendAsync(new Envelope
86+
try
5687
{
57-
Id = id,
58-
Type = MessageTypeNames.SessionListJobs,
59-
SessionId = SessionId.Value,
60-
Payload = new SessionListJobsPayload { Filter = filter, Limit = limit, Cursor = cursor },
61-
}, cancellationToken).ConfigureAwait(false);
62-
return await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
88+
await _transport.SendAsync(new Envelope
89+
{
90+
Id = id,
91+
Type = MessageTypeNames.SessionListJobs,
92+
SessionId = SessionId.Value,
93+
Payload = new SessionListJobsPayload { Filter = filter, Limit = limit, Cursor = cursor },
94+
}, cancellationToken).ConfigureAwait(false);
95+
return await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
96+
}
97+
catch
98+
{
99+
_listJobsRequests.TryRemove(id, out _);
100+
throw;
101+
}
63102
}
64103
}

src/Arcp.Client/ArcpClient.Subscriptions.cs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,32 @@ namespace Arcp.Client;
99

1010
public sealed partial class ArcpClient
1111
{
12+
/// <summary>Subscribe (asynchronous).</summary>
1213
public async Task<JobSubscription> SubscribeAsync(JobId jobId, bool history = false,
1314
long? fromEventSeq = null, CancellationToken cancellationToken = default)
1415
{
1516
var sub = new JobSubscription(this, jobId);
1617
_subscriptions[jobId] = sub;
17-
await _transport.SendAsync(new Envelope
18+
try
1819
{
19-
Type = MessageTypeNames.JobSubscribe,
20-
SessionId = SessionId.Value,
21-
JobId = jobId.Value,
22-
Payload = new JobSubscribePayload { JobId = jobId.Value, History = history, FromEventSeq = fromEventSeq },
23-
}, cancellationToken).ConfigureAwait(false);
24-
await sub.Acknowledged.WaitAsync(cancellationToken).ConfigureAwait(false);
25-
return sub;
20+
await _transport.SendAsync(new Envelope
21+
{
22+
Type = MessageTypeNames.JobSubscribe,
23+
SessionId = SessionId.Value,
24+
JobId = jobId.Value,
25+
Payload = new JobSubscribePayload { JobId = jobId.Value, History = history, FromEventSeq = fromEventSeq },
26+
}, cancellationToken).ConfigureAwait(false);
27+
await sub.Acknowledged.WaitAsync(cancellationToken).ConfigureAwait(false);
28+
return sub;
29+
}
30+
catch
31+
{
32+
_subscriptions.TryRemove(jobId, out _);
33+
throw;
34+
}
2635
}
2736

37+
/// <summary>Unsubscribe (asynchronous).</summary>
2838
public async Task UnsubscribeAsync(JobId jobId, CancellationToken cancellationToken = default)
2939
{
3040
_subscriptions.TryRemove(jobId, out _);
@@ -37,6 +47,7 @@ await _transport.SendAsync(new Envelope
3747
}, cancellationToken).ConfigureAwait(false);
3848
}
3949

50+
/// <summary>Ack (asynchronous).</summary>
4051
public ValueTask AckAsync(long lastProcessedSeq, CancellationToken cancellationToken = default) =>
4152
_transport.SendAsync(new Envelope
4253
{

src/Arcp.Client/ArcpClient.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,34 +30,45 @@ public sealed partial class ArcpClient : IAsyncDisposable
3030
private TaskCompletionSource<SessionWelcomePayload>? _welcomeTcs;
3131
private long _lastReceivedSeq;
3232
private Task? _readerLoop;
33+
private bool _disposed;
3334

35+
/// <summary>Gets the session id.</summary>
3436
public SessionId SessionId { get; private set; }
3537

38+
/// <summary>Gets the effective features.</summary>
3639
public IReadOnlyList<string> EffectiveFeatures { get; private set; } = Array.Empty<string>();
3740

41+
/// <summary>Gets the resume token.</summary>
3842
public string? ResumeToken { get; private set; }
3943

44+
/// <summary>Gets the agents.</summary>
4045
public IReadOnlyList<AgentInventoryEntry> Agents { get; private set; } = Array.Empty<AgentInventoryEntry>();
4146

47+
/// <summary>Gets the runtime.</summary>
4248
public RuntimeInfo? Runtime { get; private set; }
4349

50+
/// <summary>Gets the heartbeat interval sec.</summary>
4451
public int? HeartbeatIntervalSec { get; private set; }
4552

53+
/// <summary>Gets the last received seq.</summary>
4654
public long LastReceivedSeq => Interlocked.Read(ref _lastReceivedSeq);
4755

56+
/// <summary>Initializes a new instance of the <see cref="ArcpClient"/> class.</summary>
4857
public ArcpClient(ITransport transport, ArcpClientOptions options)
4958
{
5059
_transport = transport;
5160
_options = options;
5261
}
5362

63+
/// <summary>Connect (asynchronous).</summary>
5464
public static async Task<ArcpClient> ConnectAsync(ITransport transport, ArcpClientOptions options, CancellationToken cancellationToken = default)
5565
{
5666
var client = new ArcpClient(transport, options);
5767
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
5868
return client;
5969
}
6070

71+
/// <summary>Connect (asynchronous).</summary>
6172
public async Task ConnectAsync(CancellationToken cancellationToken = default)
6273
{
6374
_welcomeTcs = new TaskCompletionSource<SessionWelcomePayload>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -85,8 +96,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
8596
},
8697
};
8798

99+
/// <summary>Dispose (asynchronous).</summary>
88100
public async ValueTask DisposeAsync()
89101
{
102+
if (_disposed) return;
103+
_disposed = true;
90104
try
91105
{
92106
await _transport.SendAsync(new Envelope
@@ -100,7 +114,7 @@ await _transport.SendAsync(new Envelope
100114
{
101115
// Transport may already be closed; suppress on dispose path.
102116
}
103-
_cts.Cancel();
117+
try { _cts.Cancel(); } catch (ObjectDisposedException) { /* already disposed */ }
104118
await _transport.DisposeAsync().ConfigureAwait(false);
105119
_cts.Dispose();
106120
}

src/Arcp.Client/ArcpClientOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,21 @@ namespace Arcp.Client;
88
/// <summary>Configuration for <see cref="ArcpClient"/>.</summary>
99
public sealed class ArcpClientOptions
1010
{
11+
/// <summary>Gets the client.</summary>
1112
public required ClientInfo Client { get; init; }
1213

14+
/// <summary>Gets the token.</summary>
1315
public string? Token { get; init; }
1416

17+
/// <summary>Gets the auth scheme.</summary>
1518
public string AuthScheme { get; init; } = "bearer";
1619

20+
/// <summary>Gets the features.</summary>
1721
public IReadOnlyList<string>? Features { get; init; } = FeatureSet.AllFeatures;
1822

23+
/// <summary>Gets the encodings.</summary>
1924
public IReadOnlyList<string>? Encodings { get; init; } = new[] { "json" };
2025

26+
/// <summary>Gets the time provider.</summary>
2127
public TimeProvider TimeProvider { get; init; } = TimeProvider.System;
2228
}

0 commit comments

Comments
 (0)