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
11 changes: 10 additions & 1 deletion docs/architecture/client-runtime.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Client Runtime Architecture

Date: 2026-10-16
Date: 2026-10-17

Canonical current rules for client construction, transport ownership, API errors, streams, and the
local server launcher. Protocol and generated-model rules live in
Expand Down Expand Up @@ -922,6 +922,15 @@ write all run inside `opencode serve --service` (`server-process.ts`), and the e
loser's exit 0 the way upstream's does. The CLI's `reconnect` and `restart` have no door of their
own; a caller composes them from `StopAsync` and `EnsureAsync`.

Each background-service door chooses `TimeProvider.System` once at its composition root:
Discover supplies its probe; Stop shares it across the probe, handoff and terminator; Ensure
shares it across the election collaborators and spawned contenders. Internal collaborators require
that provider, including the handoff ticket's request pipeline. Request deadlines, election and
termination polls, and contender exit backoff run on it. Election deadline comparisons and spawn
stamps read UTC, and handoff expiry reads UTC epoch milliseconds; neither is a monotonic
elapsed-time policy. Each request deadline arms after probe headers, after ticket pipeline
construction, or at entry to the separate shutdown fallback, respectively.

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

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ internal sealed class ServiceContender : IServiceContender
private readonly Lock _gate = new();
private readonly Stream _stderr;
private readonly SafeProcessHandle? _process;
private readonly TimeProvider _time;
/// <summary>The handoff ticket's UTF-8 bytes, matched in the raw tail so a cut cannot split a match.</summary>
private readonly byte[]? _redact;

Expand All @@ -67,13 +68,14 @@ internal sealed class ServiceContender : IServiceContender
/// <summary>How the process ended, once a poll learned it; unknown when another reaper took the pid first.</summary>
private ChildExitStatus? _exit;

public ServiceContender(int processId, Stream stderr, SafeProcessHandle? process, string? redact)
public ServiceContender(int processId, Stream stderr, SafeProcessHandle? process, string? redact, TimeProvider time)
{
ArgumentNullException.ThrowIfNull(stderr);

ProcessId = processId;
_stderr = stderr;
_process = process;
_time = time;
_redact = string.IsNullOrEmpty(redact) ? null : Encoding.UTF8.GetBytes(redact);
_retainLimit = StderrLimit + (_redact is null ? 0 : _redact.Length - 1);

Expand Down Expand Up @@ -437,7 +439,11 @@ private async Task WatchExitAsync()
}
}

await Task.Delay(delay).ConfigureAwait(false);
#if NET
await Task.Delay(delay, _time).ConfigureAwait(false);
#else
await _time.Delay(delay).ConfigureAwait(false);
#endif
delay = delay + delay < ExitWatchCap ? delay + delay : ExitWatchCap;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ private ServiceContender SpawnUnix(
// the contender owns it from here.
var stderr = child.StandardError
?? throw new InvalidOperationException("The contender's standard error was routed to a pipe, but the spawn returned no read end.");
return new ServiceContender(child.ProcessId, stderr, null, redaction);
return new ServiceContender(child.ProcessId, stderr, null, redaction, _time);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,6 @@ private ServiceContender SpawnWindows(
throw new InvalidOperationException("The contender's standard error was routed to a pipe, but the spawn returned no read end.");
}

return new ServiceContender(child.ProcessId, stderr, child.Process, redaction);
return new ServiceContender(child.ProcessId, stderr, child.Process, redaction, _time);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ namespace OpenCode.Sdk.Internal;
/// </summary>
/// <param name="posixSpawn">The POSIX spawn the Unix arm starts contenders through.</param>
/// <param name="windowsSpawn">The Windows spawn the Windows arm starts contenders through.</param>
internal sealed partial class ServiceContenderSpawner(IPosixSpawn posixSpawn, IWindowsSpawn windowsSpawn) : IServiceContenderSpawner
/// <param name="time">The clock the contenders' exit backoff runs on.</param>
internal sealed partial class ServiceContenderSpawner(IPosixSpawn posixSpawn, IWindowsSpawn windowsSpawn, TimeProvider time) : IServiceContenderSpawner
{
/// <summary>The PTY-handoff variable whose value is secret-bearing: removed when empty, redacted from diagnostics when present.</summary>
private const string HandoffVariable = "OPENCODE_PTY_HANDOFF";

private readonly IPosixSpawn _posixSpawn = posixSpawn;
private readonly IWindowsSpawn _windowsSpawn = windowsSpawn;
private readonly TimeProvider _time = time;

/// <inheritdoc />
public IServiceContender Spawn(IServiceContenderSpawner.ContenderStartInfo startInfo) => Start(startInfo);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace OpenCode.Sdk.Internal;
/// generated info model has a different contract. Discovery keeps its own request bound and never
/// forwards credentials on redirects.
/// </summary>
internal sealed class ServiceInfoProbe(ServiceTiming timing, IServiceProbeTransport transport) : IServiceInfoProbe
internal sealed class ServiceInfoProbe(ServiceTiming timing, IServiceProbeTransport transport, TimeProvider time) : IServiceInfoProbe
{
/// <summary>The pin's info route, resolved against the authority only, as <c>new URL("/api/info", info.url)</c> does.</summary>
private const string InfoPath = "/api/info";
Expand All @@ -35,8 +35,8 @@ public async Task<ServiceProbeResult> ProbeAsync(ServiceRegistration registratio
}

// The internal bound and the caller's token are separate: only the caller's propagates.
using var bound = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
bound.CancelAfter(timing.RequestTimeout);
using var deadline = TimedCancellation.Create(timing.RequestTimeout, time);
using var bound = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token);

HttpStatusCode status;
byte[] body;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ internal sealed class ServiceElection(ServiceElectionSeams seams, EnsureRequest
/// <exception cref="OpenCodeServerException">The bound expired, the service failed, its health protocol is incompatible at a matching version, a contender failed with none live, or a spawn failed.</exception>
public async Task<ServiceRegistration> RunAsync(CancellationToken cancellationToken)
{
var deadline = seams.Clock.UtcNow + seams.Timing.PromiseTimeout;
var deadline = seams.Time.GetUtcNow() + seams.Timing.PromiseTimeout;
try
{
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
if (seams.Clock.UtcNow >= deadline)
if (seams.Time.GetUtcNow() >= deadline)
{
throw _failure ?? (_lastReplaceFailure is null
? new OpenCodeServerException(ServiceEnsurer.TimeoutMessage)
Expand Down Expand Up @@ -67,7 +67,11 @@ public async Task<ServiceRegistration> RunAsync(CancellationToken cancellationTo
await HarvestAndMaybeSpawnAsync(registration is not null, cancellationToken).ConfigureAwait(false);
}

await Task.Delay(seams.Timing.PollInterval, cancellationToken).ConfigureAwait(false);
#if NET
await Task.Delay(seams.Timing.PollInterval, seams.Time, cancellationToken).ConfigureAwait(false);
#else
await seams.Time.Delay(seams.Timing.PollInterval, cancellationToken).ConfigureAwait(false);
#endif
}
}
finally
Expand Down Expand Up @@ -107,7 +111,7 @@ private async Task RecoverUnresponsiveAsync(ServiceRegistration? registration, S
ReleaseStoppedContenders(registration.ProcessId);
_timeoutIdentity = null;
_timeoutCount = 0;
_lastSpawn = seams.Clock.UtcNow - _spawnDelay;
_lastSpawn = seams.Time.GetUtcNow() - _spawnDelay;
}

/// <summary>
Expand Down Expand Up @@ -231,7 +235,7 @@ private async Task HarvestAndMaybeSpawnAsync(bool registered, CancellationToken
{
if (_lastSpawn is null && registered)
{
_lastSpawn = seams.Clock.UtcNow;
_lastSpawn = seams.Time.GetUtcNow();
}

var finished = _contenders.Where(static contender => contender.Finished).ToList();
Expand All @@ -255,11 +259,11 @@ private async Task HarvestAndMaybeSpawnAsync(bool registered, CancellationToken

// One candidate plus one lock probe for pre-lock stalls. After a failure, the survivors
// finish without replacements that could hide the error until the bound.
if (_failure is null && _contenders.Count < 2 && (_lastSpawn is not { } spawned || seams.Clock.UtcNow - spawned >= _spawnDelay))
if (_failure is null && _contenders.Count < 2 && (_lastSpawn is not { } spawned || seams.Time.GetUtcNow() - spawned >= _spawnDelay))
{
Announce(OpenCodeServerEnsureReason.Missing, previousVersion: null);
_contenders.Add(await SpawnAsync(cancellationToken).ConfigureAwait(false));
_lastSpawn = seams.Clock.UtcNow;
_lastSpawn = seams.Time.GetUtcNow();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace OpenCode.Sdk.Internal;
/// <param name="Handoff">The persistent-terminal handoff sidecar.</param>
/// <param name="Terminator">The stop ladder the recovery and the replacement end a daemon with.</param>
/// <param name="FileSystem">The registration and service-config file access.</param>
/// <param name="Clock">The wall clock the deadline and the spawn delay are measured on.</param>
/// <param name="Time">The clock for polling and the UTC deadline and spawn stamps.</param>
/// <param name="Executables">The launcher's executable resolution for the command's first entry.</param>
/// <param name="Timing">The lifecycle timing.</param>
internal sealed record ServiceElectionSeams(
Expand All @@ -18,6 +18,6 @@ internal sealed record ServiceElectionSeams(
IServicePtyHandoff Handoff,
ServiceTerminator Terminator,
IServiceFileSystem FileSystem,
IServiceClock Clock,
TimeProvider Time,
ExecutableResolver Executables,
ServiceTiming Timing);
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ internal sealed class ServiceEnsurer(
IServiceContenderSpawner spawner,
IServicePtyHandoff handoff,
IServiceProcessControl processControl,
IServiceClock clock,
TimeProvider time,
ExecutableResolver executableResolver,
ServiceTiming timing)
{
Expand Down Expand Up @@ -59,9 +59,9 @@ public async Task<ServiceRegistration> EnsureAsync(OpenCodeServerEnsureOptions?
probe,
spawner,
handoff,
new ServiceTerminator(fileSystem, processControl, timing),
new ServiceTerminator(fileSystem, processControl, timing, time),
fileSystem,
clock,
time,
executableResolver,
timing);
return await new ServiceElection(seams, request, paths).RunAsync(cancellationToken).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace OpenCode.Sdk.Internal;
/// but the caller's cancellation replaces the service without a handoff.
/// Time comes from the injected clock so every expiry rule is testable without waiting.
/// </summary>
internal sealed class ServicePtyHandoff(IServiceFileSystem fileSystem, IServiceClock clock, IServicePtyShutdown ptyShutdown)
internal sealed class ServicePtyHandoff(IServiceFileSystem fileSystem, TimeProvider time, IServicePtyShutdown ptyShutdown)
: IServicePtyHandoff
{
/// <summary>The sidecar suffix the pinned client keeps beside the registration.</summary>
Expand Down Expand Up @@ -140,7 +140,7 @@ public Task ClearAsync(string registrationFile, CancellationToken cancellationTo
/// The handoff route through the SDK's own request pipeline — the transport, credential, and
/// status mapping every generated door uses — with the answer kept raw, under the request bound.
/// </summary>
private static async Task<ServiceHandoffTicketResponse> RequestTicketAsync(
private async Task<ServiceHandoffTicketResponse> RequestTicketAsync(
ServiceRegistration registration,
TimeSpan timeout,
CancellationToken cancellationToken)
Expand All @@ -149,9 +149,9 @@ private static async Task<ServiceHandoffTicketResponse> RequestTicketAsync(
{
Endpoint = registration.Endpoint,
Password = registration.Password,
});
using var bound = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
bound.CancelAfter(timeout);
}, time);
using var deadline = TimedCancellation.Create(timeout, time);
using var bound = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token);
return await pipeline
.ExecuteAsync("persistentPty.handoff", HttpMethod.Post, OpenCodeRoutes.PersistentPtys.Handoff, ServiceHandoffTicketAdapter.Instance, options: null, bound.Token)
.ConfigureAwait(false);
Expand Down Expand Up @@ -193,8 +193,8 @@ private PtyHandoffSidecar Parse(ServiceHandoffTicketResponse answer, ServiceRegi
"The pinned client's shutdown .catch(() => {}) is best-effort (prepare in pty-handoff.ts): endpoint refusal or a failed shutdown leads to a null sidecar and replacement without these terminals.")]
private async Task ShutdownAsync(ServiceRegistration registration, TimeSpan timeout, CancellationToken cancellationToken)
{
using var bound = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
bound.CancelAfter(timeout);
using var deadline = TimedCancellation.Create(timeout, time);
using var bound = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token);
try
{
await ptyShutdown.ShutdownAsync(registration, bound.Token).ConfigureAwait(false);
Expand Down Expand Up @@ -319,5 +319,5 @@ private static bool Matches(PtyHandoffSidecar sidecar, ServiceRegistration regis
sidecar.SourcePid == registration.ProcessId &&
string.Equals(sidecar.SourceUrl, registration.Url, StringComparison.Ordinal);

private double NowMilliseconds() => clock.UtcNow.ToUnixTimeMilliseconds();
private double NowMilliseconds() => time.GetUtcNow().ToUnixTimeMilliseconds();
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ internal sealed class ServiceStopper(
IServicePtyShutdown ptyShutdown,
IServicePtyHandoff ptyHandoff,
IServiceProcessControl processControl,
ServiceTiming timing)
ServiceTiming timing,
TimeProvider time)
{
/// <summary>Stops the registered service a selection points at.</summary>
/// <param name="options">The caller's options; null means every default.</param>
Expand Down Expand Up @@ -50,7 +51,7 @@ public async Task StopAsync(OpenCodeServerStopOptions? options, CancellationToke
await ClearSidecarAsync(paths.RegistrationFile, cancellationToken).ConfigureAwait(false);
if (registration is not null)
{
await new ServiceTerminator(fileSystem, processControl, timing)
await new ServiceTerminator(fileSystem, processControl, timing, time)
.TerminateAsync(registration, paths.RegistrationFile, cancellationToken)
.ConfigureAwait(false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace OpenCode.Sdk.Internal;
/// reused reads as the registered process being gone. The one failure is a process still running
/// after the kill rung; the registration then stays.
/// </summary>
internal sealed class ServiceTerminator(IServiceFileSystem fileSystem, IServiceProcessControl processControl, ServiceTiming timing)
internal sealed class ServiceTerminator(IServiceFileSystem fileSystem, IServiceProcessControl processControl, ServiceTiming timing, TimeProvider time)
{
/// <summary>Ends the registered process and removes its registration.</summary>
/// <param name="registration">The registration as it was read.</param>
Expand Down Expand Up @@ -87,7 +87,11 @@ private async Task<bool> WaitForExitAsync(ProcessIdentity target, CancellationTo

if (attempt < timing.StopPollAttempts)
{
await Task.Delay(timing.StopPollInterval, cancellationToken).ConfigureAwait(false);
#if NET
await Task.Delay(timing.StopPollInterval, time, cancellationToken).ConfigureAwait(false);
#else
await time.Delay(timing.StopPollInterval, cancellationToken).ConfigureAwait(false);
#endif
}
}

Expand Down
Loading
Loading