Skip to content
Merged
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
14 changes: 14 additions & 0 deletions BannedSymbols.Clock.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
M:System.Threading.Tasks.Task.Delay(System.TimeSpan);Use the injected TimeProvider overload
M:System.Threading.Tasks.Task.Delay(System.TimeSpan,System.Threading.CancellationToken);Use the injected TimeProvider overload
M:System.Threading.Tasks.Task.Delay(System.Int32);Use the injected TimeProvider overload
M:System.Threading.Tasks.Task.Delay(System.Int32,System.Threading.CancellationToken);Use the injected TimeProvider overload
M:System.Threading.CancellationTokenSource.#ctor(System.TimeSpan);Use a cancellation deadline on the injected TimeProvider
M:System.Threading.CancellationTokenSource.#ctor(System.Int32);Use a cancellation deadline on the injected TimeProvider
M:System.Threading.CancellationTokenSource.CancelAfter(System.TimeSpan);Use a cancellation deadline on the injected TimeProvider
M:System.Threading.CancellationTokenSource.CancelAfter(System.Int32);Use a cancellation deadline on the injected TimeProvider
T:System.Diagnostics.Stopwatch;Use GetTimestamp and GetElapsedTime on the injected TimeProvider
P:System.DateTime.UtcNow;Use the injected TimeProvider (GetUtcNow or GetTimestamp)
P:System.DateTimeOffset.UtcNow;Use the injected TimeProvider (GetUtcNow or GetTimestamp)
P:System.Environment.TickCount;Use the injected TimeProvider (GetUtcNow or GetTimestamp)
P:System.Environment.TickCount64;Use the injected TimeProvider (GetUtcNow or GetTimestamp)
P:System.TimeProvider.System;Choose the clock only at a counted composition root and inject it elsewhere
4 changes: 2 additions & 2 deletions BannedSymbols.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.BannedApiAnalyzers/BannedApiAnalyzers.Help.md
P:System.DateTime.Now;Use System.DateTime.UtcNow instead
P:System.DateTimeOffset.Now;Use System.DateTimeOffset.UtcNow instead
P:System.DateTime.Now;Use the injected TimeProvider (src) or DateTime.UtcNow (tests)
P:System.DateTimeOffset.Now;Use the injected TimeProvider (src) or DateTimeOffset.UtcNow (tests)
P:System.DateTimeOffset.DateTime;Use System.DateTimeOffset.UtcDateTime instead
M:Polyfills.Polyfill.CancelAsync(System.Threading.CancellationTokenSource);Spins a worker until a queued Cancel starts and stalls the pool under concurrency; use OpenCode.Sdk.Internal.CancellationTokenSourceExtensions.CancelOnWorkerAsync
M:Polyfills.Polyfill.Kill(System.Diagnostics.Process,System.Boolean);Polyfill walks the tree itself: on Unix it shells out to ps, kills only the root without an error when ps fails, and SIGKILLs descendants by bare pid; end a tree with taskkill /T (OpenCode.Sdk.Internal.TaskkillTreeKill, or TestProcessTreeKill in tests)
Expand Down
4 changes: 4 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" Link="Properties/BannedSymbols.txt"/>
</ItemGroup>

<ItemGroup Condition="'$(MSBuildProjectName)' == 'OpenCode.Sdk' Or '$(MSBuildProjectName)' == 'OpenCode.Sdk.Extensions'">
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.Clock.txt" Link="Properties/BannedSymbols.Clock.txt"/>
</ItemGroup>

<!-- Downlevel support: Polyfill (source-only, internal) supplies the compiler attributes
C# 14 needs on netstandard2.0/net472 plus BCL API polyfills (Stream Memory overloads,
Task.WaitAsync, ...). Upstream guidance: reference it on ALL TFMs, not just downlevel. -->
Expand Down
8 changes: 7 additions & 1 deletion docs/architecture/client-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -617,8 +617,11 @@ completion; only the absence of those outcomes is a readiness timeout. An early-
drain still gives the first readiness line priority. Cancellation arriving during cleanup does not
replace the failure already selected.

Readiness, drain, both disposal ladders, background reap, reader release and tree-kill bounds run
on `LauncherSeams.Time`, chosen once by the standalone launcher's composition root.

Disposal is a ladder, bounded at every step so it never hangs the caller. On Windows it follows
upstream's rungs on upstream's clock: the grace (`GracefulShutdownTimeout`, default 3 seconds — the
upstream's rungs and timing: the grace (`GracefulShutdownTimeout`, default 3 seconds — the
reference client's own force-kill window) starts with the first rung and bounds it.

1. A root that already exited is left alone: `taskkill` finds only live processes, so on an exited
Expand Down Expand Up @@ -934,6 +937,9 @@ stamps read UTC, and handoff expiry reads UTC epoch milliseconds; neither is a m
elapsed-time policy. Each request deadline arms after probe headers, after ticket pipeline
construction, or at entry to the separate shutdown fallback, respectively.

`BannedSymbols.Clock.txt` bans the system clock in shipped src outside the five counted
composition roots: launcher, client pipeline, Discover, Stop and Ensure.

Where the SDK deliberately differs from the pinned chain, it differs here, each for a recorded
reason (ADR-0031):

Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/platform-and-packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ document. A polyfilled member whose downlevel behaviour is a hazard is banned in
`BannedSymbols.txt`, which names its replacement. `CancellationTokenSource.CancelAsync` is one: below
.NET 8 it spins the calling worker until a queued `Cancel` starts, and concurrent callers stall the
thread pool, so product and test code cancel through the internal `CancelOnWorkerAsync`.
The src-only `BannedSymbols.Clock.txt` enforces clock injection
([client-runtime.md](client-runtime.md)).

### WebSocket support

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ public partial class OpenCodeServer
OpenCodeServerDiscoverOptions? options = null,
CancellationToken cancellationToken = default)
{
// The discovery composition root chooses the clock for its info probe.
#pragma warning disable RS0030 // The discovery composition root chooses the system clock and passes it to its info probe.
var time = TimeProvider.System;
#pragma warning restore RS0030
return DiscoverWithSeamsAsync(options, PlatformProbe(ServiceTiming.Default, time), cancellationToken);
}

Expand Down Expand Up @@ -86,8 +87,9 @@ public static Task StopAsync(
CancellationToken cancellationToken = default)
{
var timing = ServiceTiming.Default;
// The stop composition root shares one clock across its probe, handoff and terminator.
#pragma warning disable RS0030 // The stop composition root chooses the system clock; its probe, handoff and terminator share it.
var time = TimeProvider.System;
#pragma warning restore RS0030
var fileSystem = new ServiceFileSystem();
var ptyShutdown = new ServicePtyShutdown();
var stopper = new ServiceStopper(
Expand Down Expand Up @@ -120,8 +122,9 @@ public static Task<OpenCodeServer> EnsureAsync(
OpenCodeServerEnsureOptions? options = null,
CancellationToken cancellationToken = default)
{
// The ensure composition root shares one clock across the election and its contenders.
#pragma warning disable RS0030 // The ensure composition root chooses the system clock; election collaborators and spawned contenders receive it.
var time = TimeProvider.System;
#pragma warning restore RS0030
return EnsureWithSeamsAsync(options, ServiceTiming.Default, new ServiceContenderSpawner(new PosixSpawn(), new WindowsSpawn(), time), time, cancellationToken);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ namespace OpenCode.Sdk.Internal;
/// </summary>
/// <param name="spawn">The spawn the kill runs through.</param>
/// <param name="exits">The exit watch the kill's own exit is awaited through.</param>
internal sealed class TaskkillTreeKill(IWindowsSpawn spawn, IWindowsChildExit exits) : IWindowsTreeKill
/// <param name="time">The clock for the supplied tree-kill bound.</param>
internal sealed class TaskkillTreeKill(IWindowsSpawn spawn, IWindowsChildExit exits, TimeProvider time) : IWindowsTreeKill
{
/// <summary>
/// Gets the absolute path to the system taskkill.exe. The system folder is read from the
Expand Down Expand Up @@ -59,7 +60,7 @@ public async Task<TreeKillOutcome> KillTreeAsync(int processId, TimeSpan bound)

using (watch)
{
if (!await BoundedWait.CompletesWithinAsync(watch.Exited, bound).ConfigureAwait(false))
if (!await BoundedWait.CompletesWithinAsync(watch.Exited, bound, time).ConfigureAwait(false))
{
// Ended, not awaited: the watch releases its handle once it is gone, and the
// caller's own bound is not stretched by the time that takes.
Expand Down
15 changes: 3 additions & 12 deletions src/OpenCode.Sdk/Hosting/Launcher/Internal/BoundedWait.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,11 @@ namespace OpenCode.Sdk.Internal;
/// <summary>Waits for a task under a bound, reporting an expired bound instead of raising it.</summary>
internal static class BoundedWait
{
/// <summary>
/// Waits at most <paramref name="bound"/> for <paramref name="task"/> to end. A task that
/// faulted or was canceled has ended too: the wait reports that and raises nothing, and how the
/// task ended stays on the task for its owner to read.
/// </summary>
/// <param name="task">The awaited task.</param>
/// <param name="bound">How long the caller waits.</param>
/// <returns>True when the task ended inside the bound, however it ended.</returns>
public static Task<bool> CompletesWithinAsync(Task task, TimeSpan bound) =>
CompletesWithinAsync(task, bound, TimeProvider.System);

/// <summary>
/// Waits at most <paramref name="bound"/>, measured on <paramref name="time"/>, for
/// <paramref name="task"/> to end, as <see cref="CompletesWithinAsync(Task, TimeSpan)"/> does.
/// <paramref name="task"/> to end. A faulted or canceled task has ended too, as
/// <see cref="Task.IsCompleted"/> reports: the wait raises nothing, and how the task ended
/// stays on the task for its owner to read.
/// </summary>
/// <param name="task">The awaited task.</param>
/// <param name="bound">How long the caller waits.</param>
Expand Down
9 changes: 6 additions & 3 deletions src/OpenCode.Sdk/Hosting/Launcher/Internal/LauncherSeams.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ internal sealed record LauncherSeams
/// <summary>Gets the POSIX exit status.</summary>
public required IChildExitStatus Exits { get; init; }

/// <summary>Gets the clock for the readiness bound and the server's early-end drain.</summary>
/// <summary>Gets the clock for readiness, drain, both disposal ladders, background reap, reader release, and tree-kill bounds.</summary>
public required TimeProvider Time { get; init; }

/// <summary>Gets an optional token-bound readiness wait substitution; null uses the ordinary task wait.</summary>
Expand All @@ -49,16 +49,19 @@ public static LauncherSeams ForCurrentProcess()
{
var windowsSpawn = new WindowsSpawn();
var windowsExits = new WindowsChildExit();
#pragma warning disable RS0030 // The standalone launcher composition root chooses the system clock; child bounds receive this provider.
var time = TimeProvider.System;
#pragma warning restore RS0030
return new LauncherSeams
{
WindowsSpawn = windowsSpawn,
Job = KillOnCloseJob.ForCurrentProcess,
WindowsExits = windowsExits,
TreeKill = new TaskkillTreeKill(windowsSpawn, windowsExits),
TreeKill = new TaskkillTreeKill(windowsSpawn, windowsExits, time),
Spawn = new PosixSpawn(),
Signals = new ProcessGroupSignal(),
Exits = new ChildExitStatusReader(),
Time = TimeProvider.System,
Time = time,
HostEnvironment = SnapshotEnvironment(),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ public async ValueTask DisposeAsync()
await lease.DisposeAsync().ConfigureAwait(false);
}

await _process.Output.ReleaseAsync().ConfigureAwait(false);
await _process.Output.ReleaseAsync(_time).ConfigureAwait(false);
}
finally
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ internal sealed class PipeOutputPump
/// covers a runtime whose pipe read neither the token nor the closed stream reaches, and a
/// release never hangs its caller.
/// </summary>
private static readonly TimeSpan ReleaseBound = TimeSpan.FromSeconds(5);
internal static readonly TimeSpan ReleaseBound = TimeSpan.FromSeconds(5);

private readonly PipeLineReader _standardOutput;
private readonly PipeLineReader? _standardError;
Expand Down Expand Up @@ -65,16 +65,17 @@ public async Task<bool> DrainAsync(TimeSpan bound, TimeProvider time)
/// Ends every reader, whether or not its pipe reached end-of-stream, and closes the pipes.
/// After it completes no reader is pending.
/// </summary>
/// <param name="time">The owned child's clock for the release bound.</param>
/// <returns>A task that completes once the readers are released.</returns>
public async Task ReleaseAsync()
public async Task ReleaseAsync(TimeProvider time)
{
await _standardOutput.DisposeAsync().ConfigureAwait(false);
if (_standardError is not null)
{
await _standardError.DisposeAsync().ConfigureAwait(false);
}

_ = await BoundedWait.CompletesWithinAsync(ReadersEnded, ReleaseBound).ConfigureAwait(false);
_ = await BoundedWait.CompletesWithinAsync(ReadersEnded, ReleaseBound, time).ConfigureAwait(false);
}

/// <summary>Whether an ended reader reached end-of-stream; one whose line handler failed did not.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ private static async Task<OwnedServerChild> AdoptAsync(
?? throw new InvalidOperationException("The server's standard output was routed to a pipe, but the spawn returned no read end.");
var pump = PipeOutputPump.Start(standardOutput, spawned.StandardError, start.OnStandardOutput, start.OnStandardError);
watch = PosixChildExitWatch.Start(spawned.ProcessId, seams.Exits, seams.Signals, childrenReapedAutomatically);
var ladder = new PosixServerLadder(spawned.ProcessId, watch, seams.Signals, PosixServerLadder.ForcedExitTimeout);
var ladder = new PosixServerLadder(spawned.ProcessId, watch, seams.Signals, PosixServerLadder.ForcedExitTimeout, seams.Time);
return new OwnedServerChild(
new OwnedServerProcess
{
Expand Down
20 changes: 13 additions & 7 deletions src/OpenCode.Sdk/Hosting/Launcher/Internal/PosixServerLadder.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Diagnostics;

namespace OpenCode.Sdk.Internal;

/// <summary>
Expand Down Expand Up @@ -33,21 +31,25 @@ internal sealed class PosixServerLadder : IServerLadder
private readonly PosixChildExitWatch _watch;
private readonly IProcessGroupSignal _signals;
private readonly TimeSpan _forcedExitTimeout;
private readonly TimeProvider _time;

/// <summary>Initializes the ladder for one server.</summary>
/// <param name="processGroupId">The server's pid, which names its group.</param>
/// <param name="watch">The server's exit watch, which also guards the pid fallback.</param>
/// <param name="signals">The signal seam.</param>
/// <param name="forcedExitTimeout">How long the last rung waits; the shipped value is <see cref="ForcedExitTimeout"/>.</param>
public PosixServerLadder(int processGroupId, PosixChildExitWatch watch, IProcessGroupSignal signals, TimeSpan forcedExitTimeout)
/// <param name="time">The clock for the shared grace and forced-root wait.</param>
public PosixServerLadder(int processGroupId, PosixChildExitWatch watch, IProcessGroupSignal signals, TimeSpan forcedExitTimeout, TimeProvider time)
{
ArgumentNullException.ThrowIfNull(watch);
ArgumentNullException.ThrowIfNull(signals);
ArgumentNullException.ThrowIfNull(time);

_processGroupId = processGroupId;
_watch = watch;
_signals = signals;
_forcedExitTimeout = forcedExitTimeout;
_time = time;
}

/// <summary>Runs the ladder; bounded by the grace plus the forced-exit wait.</summary>
Expand Down Expand Up @@ -92,7 +94,7 @@ private bool TrySignal(ProcessSignal signal) =>

private async Task<bool> GroupEndedWithinAsync(TimeSpan grace)
{
var elapsed = Stopwatch.StartNew();
var started = _time.GetTimestamp();
if (!await RootExitedWithinAsync(grace).ConfigureAwait(false))
{
return false;
Expand All @@ -101,13 +103,17 @@ private async Task<bool> GroupEndedWithinAsync(TimeSpan grace)
var interval = FirstProbeInterval;
while (_signals.ProbeGroup(_processGroupId) is not SignalDelivery.NoSuchTarget)
{
var remaining = grace - elapsed.Elapsed;
var remaining = grace - _time.GetElapsedTime(started);
if (remaining <= TimeSpan.Zero)
{
return false;
}

await Task.Delay(Shorter(interval, remaining)).ConfigureAwait(false);
#if NET
await Task.Delay(Shorter(interval, remaining), _time).ConfigureAwait(false);
#else
await _time.Delay(Shorter(interval, remaining)).ConfigureAwait(false);
#endif
interval = Shorter(interval + interval, LongestProbeInterval);
}

Expand All @@ -118,7 +124,7 @@ private async Task<bool> RootExitedWithinAsync(TimeSpan window)
{
try
{
_ = await _watch.Exited.WaitAsync(window).ConfigureAwait(false);
_ = await _watch.Exited.WaitAsync(window, _time).ConfigureAwait(false);
return true;
}
catch (TimeoutException)
Expand Down
2 changes: 2 additions & 0 deletions src/OpenCode.Sdk/Internal/Transport/Pipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, OpenCodeClientOpti
_framer = framer ?? new ServerSentEventFramer();
_clientNetworkTimeout = networkTimeout ?? options.NetworkTimeout;
_eventStreamIdleWindow = options.EventStreamIdleTimeout ?? StreamReconnect.DefaultIdleWindow;
#pragma warning disable RS0030 // The client pipeline composition root chooses the system clock only when no internal provider was supplied.
_time = time ?? TimeProvider.System;
#pragma warning restore RS0030
_lifetimeToken = _lifetime.Token;

// The options are read exactly once, here: the policies hold an immutable snapshot,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ public async Task DisposeAsync_Should_Return_Promptly_When_A_Descendant_Holds_St
holderRunningAtClose = ProcessObservation.IsRunning(holderId.Value);
var clock = Stopwatch.StartNew();
disposal = server.DisposeAsync().AsTask();
closed = await BoundedWait.CompletesWithinAsync(disposal, CloseHangGuard);
closed = await BoundedWait.CompletesWithinAsync(disposal, CloseHangGuard, TimeProvider.System);
elapsed = clock.Elapsed;
if (closed)
{
Expand Down Expand Up @@ -667,7 +667,7 @@ public async Task DisposeAsync_Should_Return_Promptly_When_A_Descendant_Holds_St
else if (disposal is { IsCompleted: false })
{
// A close still waiting for end-of-stream gets it from the holder's end and finishes.
_ = await BoundedWait.CompletesWithinAsync(disposal, CloseHangGuard);
_ = await BoundedWait.CompletesWithinAsync(disposal, CloseHangGuard, TimeProvider.System);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,13 @@ private static async Task AdvanceEarlyEndAsync(
await draining.WaitAsync(SdkTestSupport.HangGuard.Bound, cancellationToken);
time.Advance(OwnedServerChild.EarlyEndBound);
}

var releasing = time.WaitForArmedAsync(PipeOutputPump.ReleaseBound, SdkTestSupport.HangGuard.Bound, count: 1);
if (await Task.WhenAny(Task.WhenAll(startingTasks), releasing).WaitAsync(SdkTestSupport.HangGuard.Bound, cancellationToken) == releasing)
{
await releasing.WaitAsync(SdkTestSupport.HangGuard.Bound, cancellationToken);
time.Advance(PipeOutputPump.ReleaseBound);
}
}

private static LauncherSeams ObserveSpawn(
Expand Down
Loading
Loading