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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<PackageVersion Include="Roslynator.Formatting.Analyzers" Version="4.15.0" />
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.31.0.145097" />
<PackageVersion Include="NSubstitute.Analyzers.CSharp" Version="1.0.17" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.11" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.Analyzers" Version="2022.0.0" />
<!-- aspire packages -->
<PackageVersion Include="Aspire.Hosting" Version="13.4.6" />
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,11 @@ Architecture, decision records, and engineering policy live under [`docs/`](docs
example); a larger-capacity pool is a measured, benchmark-gated follow-up rather than a
speculative change.

- **`PtySession.ReadAsync()` allocates its receive buffer per call.** Each read rents nothing and
allocates a fresh 16 KiB buffer — measured at 16,776 bytes on the complete read path versus
24 bytes for decoding alone. Correctness is unaffected; a tight read loop over a chatty terminal
will produce more garbage than it needs to. Pooling the buffer is a queued optimization, held
behind the same benchmark gate as the item above so the change ships with evidence.
- **Each terminal connection allocates a 16 KiB receive buffer.** The connection-owned receiver
reuses it across messages and consumer read enumerations. Pooling remains a benchmark-gated
follow-up; repeated reads on the same connection do not allocate another receive buffer. The
receiver also queues undelivered frames, so slow or absent consumers can grow memory; see the
[terminal lifetime contract](docs/architecture/client-runtime.md).

- **Attaching to an existing background service is not implemented.** opencode's third connection
mode — discovering a registered daemon through its registration file (`Service.discover` /
Expand Down
13 changes: 9 additions & 4 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Roadmap

Date: 2026-09-07
Date: 2026-09-08

Operational state: what ships today, what is queued next, what is still open, and what is known to
be incomplete. This file is a summary and shrinks as work lands. `../AGENTS.md` routes to the
Expand All @@ -23,6 +23,11 @@ accepted OpenAPI snapshot and rides one hand-written transport runtime.
- **Assurance** — the suite runs on `net8.0`, `net9.0`, and `net10.0` across Linux, Windows, and
macOS, plus `net472` on Windows, the fullest leg; `engineering/quality-gates.md` owns the gate a
change must pass before it is called done.
- **Terminal lifetime corrections (D)** — consumer cancellation, send deadlines, local viewport
ordering, and shared cleanup are implemented and verified on Windows across the four runnable
targets. Linux and macOS live verification also passed on net8/net9/net10, including the persistent daemon
round trip and normal PTY reuse after read cancellation. `architecture/client-runtime.md` and
ADR-0023 own the contract.
- **Packages** — `OpenCode.Sdk` and `OpenCode.Sdk.Extensions` pack at the single-sourced
`VersionPrefix 0.1.0`, and every `master` push publishes a `0.1.0-nightly.*` build to GitHub
Packages. NuGet.org publication is currently blocked by an upstream prefix reservation dispute;
Expand Down Expand Up @@ -103,9 +108,9 @@ is revisited at each boundary.
path the call must carry is invisible to any generated client; admitting it would mean inventing a
path parameter the document does not declare (ADR-0013), and the upstream report is drafted.
- **Two allocation follow-ups are queued behind a benchmark gate** — on `net472` and
`netstandard2.0` a response body over 1 MB costs one wire-sized copy, and `PtySession.ReadAsync`
allocates a fresh 16 KiB receive buffer per call. Both are measured rather than suspected, and
both are described for consumers in the README's Known Issues.
`netstandard2.0` a response body over 1 MB costs one wire-sized copy, and each terminal connection
allocates one 16 KiB receive buffer, reused across consumer reads. Both are described for consumers
in the README's Known Issues; pooling requires evidence under the current connection-lifetime harness.
- **A half-open event stream is not detected.** A successful SSE body stays live until caller
cancellation, server completion, or failure, so a connection whose peer is gone without closing
hangs a consumer that supplied no cancellation of its own; ordinary resets, server exits, and
Expand Down
13 changes: 13 additions & 0 deletions docs/adr/0023-terminal-consumer-cancellation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Terminal consumer cancellation is independent of connection lifetime

Date: 2026-09-08

A terminal consumer needs to stop waiting for output and later read or write on the same
attachment. Passing its cancellation token to physical WebSocket receive can abort that
connection. Both terminal families therefore use a connection-owned receiver and an unbounded
internal channel; public enumeration cancellation affects only the consumer. This is an SDK
contract, not a guarantee implied by `IAsyncEnumerable` or an upstream cancel/resume API.
The queue retains undelivered frames and may grow with an absent or slow consumer. A memory
limit and overflow policy require workload evidence before changing this contract. Connection
termination preserves queued output before reporting its outcome; explicit disposal abandons
unread output and ends owned work without requiring consumer progress.
53 changes: 39 additions & 14 deletions docs/architecture/client-runtime.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Client Runtime Architecture

Date: 2026-09-07
Date: 2026-09-08

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 @@ -89,8 +89,8 @@ local server launcher. Protocol and generated-model rules live in
### PTY WebSocket session

- `PtyClient.ConnectAsync` opens `PtySession`, the family's live working object: `ReadAsync`
enumerates `PtyFrame` values, `WriteAsync` sends input, and `DisposeAsync` closes. The session
owns its socket, so disposing it is the only way to end the connection.
enumerates `PtyFrame` values, `WriteAsync` sends input, and `DisposeAsync` closes. The caller
owns disposal of the session; peer closure and transport failure can also end the connection.
- **Transport divergence.** This is one of the two SDK doors that do not ride the HTTP pipeline
(the persistent PTY session is the other). The upgrade builds its own `ClientWebSocket`, so a
caller-supplied `HttpClient`, its proxy, its handler chain, the redirect policy, the
Expand Down Expand Up @@ -137,12 +137,33 @@ local server launcher. Protocol and generated-model rules live in
exited and throws with the reason; because an exited PTY still upgrades cleanly, that failure
surfaces on the first read rather than on connect. Any other close is an abnormal close, and a
socket fault maps through `FailureClassification`'s PTY WebSocket phases.
- **Concurrency and disposal.** One session carries one active read enumeration — message
reassembly cannot be shared — and a second concurrent enumeration is refused with
`InvalidOperationException`. Sends are serialized behind a semaphore because the socket allows
one outstanding send. Caller cancellation stays `OperationCanceledException`. Disposal closes
gracefully under a bounded wait, then tears the socket down; it is idempotent, and a dispose
racing a pending read completes that read as a normal end rather than a fault.
- **Concurrency and reading (both terminal families).** One active public read enumeration is
allowed; a concurrent second reader throws `InvalidOperationException`. A connection-owned
receiver assembles complete messages into an unbounded `Channel<TFrame>` independently of
public enumeration. Canceling or disposing an enumerator ends only that consumer; another
enumeration may continue on the same otherwise healthy connection. The queue retains only
undelivered frames, and can grow when consumers are absent or slow. Connection termination
delivers the valid queued prefix before normal completion or the recorded transport/protocol
error. Later readers observe the same terminal outcome; consumer cancellation does not erase it.
A frame already selected for delivery may win a race with cancellation.
- **Explicit disposal (both terminal families).** The session owns its socket and receiver.
Disposal rejects new sends, wakes waiters, attempts bounded graceful close, then tears down
the socket and awaits owned I/O completion. It releases unread queued references without
waiting for consumer processing. Concurrent disposal calls await the same cleanup. Reads
after disposal are empty; writes after disposal throw `ObjectDisposedException`. A pending
read ends normally unless its own cancellation was already observed first. Expected graceful
close failures do not prevent hard cleanup. The graceful-close bounds are separate waits
for send admission and close-output; they are not one total budget or the send-timeout setting.
- **Send timeout (both terminal families).** `PtyConnectOptions.SendTimeout` and
`PersistentPtyConnectOptions.SendTimeout` configure a fixed total budget for each WebSocket
input or resize send, including serialization wait. Their default is 30 seconds. The option
accepts 1 millisecond through `TimeSpan.FromMilliseconds(int.MaxValue)`, inclusive, is captured
at connect, and never travels on the wire. Progress does not restart the budget. Expiry before
socket entry fails only that call; interruption during physical send terminates the attachment
because delivery is uncertain. Caller cancellation reports `OperationCanceledException`;
SDK expiry reports `OpenCodeTransportException` with a timeout cause. No send is retried.
This setting does not govern connection establishment, HTTP operations, idle time, command
execution, or disposal.

### Persistent PTY family

Expand Down Expand Up @@ -192,9 +213,12 @@ local server launcher. Protocol and generated-model rules live in
- **Input.** `WriteAsync(ReadOnlyMemory<byte>)` and `ResizeAsync(cols, rows)` each send one binary
message in the framed input protocol's layout — `[type u8][cols u16 BE][rows u16 BE][data]`,
type 1 for input and type 0 for a viewport change — which the SDK negotiates on every connection
and is the only protocol it writes. The viewport is a wire fact, not a caller preference: the
session starts it at the attachment's size and follows every resize frame the read enumeration
yields, whoever caused it, so a later write carries the size the server believes. Input from a
and is the only protocol it writes. The outbound viewport expresses this attachment's local
size intent. It starts at the attachment's size, and a successful `ResizeAsync` send changes
the size carried by later input. An inbound resize report remains available to the consumer
for rendering but does not overwrite that local intent. Header preparation, socket send, and
successful resize-state publication share one serialization boundary, so each input carries
a coherent size in send order. Input from a
connection the server attached as an observer is accepted here and dropped there.
- **Cursor.** `PersistentPtyConnectOptions.Cursor` is a relay to the connect query and nothing
more. There is no live-only mode here: null replays from the oldest retained byte, and zero means
Expand Down Expand Up @@ -223,8 +247,9 @@ local server launcher. Protocol and generated-model rules live in
an unknown id, `shutdown` a 204, and `connect` a 4404 close. `ShutdownAsync` ends every terminal
the daemon owns, not one.
- **Shared core.** Both families' sessions run on the internal, family-neutral
`TerminalSocketCore<TFrame>` — receive with fragment reassembly, serialized sends, a bounded
graceful close, idempotent disposal, and one active read enumeration — and differ only behind
`TerminalSocketCore<TFrame>` — a connection-owned receiver with fragment reassembly, an
unbounded consumer queue, serialized sends with per-call deadlines, bounded graceful close, idempotent disposal,
and one active public read enumeration — and differ only behind
three named seams: `ITerminalFrameDecoder<TFrame>` for what a message carries and
`ITerminalClosePolicy` for what a close status means, both consumed by the core, and
`ITerminalUpgradeFailurePolicy` for what a refused upgrade means, consumed by the shipped
Expand Down
6 changes: 5 additions & 1 deletion docs/architecture/platform-and-packaging.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Platform and Packaging Architecture

Date: 2026-08-27
Date: 2026-09-08

Canonical current rules for target frameworks, package boundaries, repository shape, versioning,
distribution, dependencies, and licensing.
Expand Down Expand Up @@ -54,6 +54,10 @@ Two separate API vintages shape the downlevel legs, and conflating them would mi

## Dependencies

- Terminal sessions use `System.Threading.Channels` for their internal receive queue. The
SDK declares that bridge package directly for `net472` and `netstandard2.0`; modern targets
use the inbox implementation. Channel types are not part of the public SDK API.

- Declare explicitly every package this repository's source uses directly, that appears on a
public surface, or that is version-pinned for behavior; trust the transitive graph otherwise.
- Downlevel bridge packages (`System.Memory`, `System.Buffers`, `System.Collections.Immutable`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace OpenCode.Sdk.Internal.Abstractions;

/// <summary>Creates the connection's per-operation cancellation timers.</summary>
internal interface ITerminalDeadlineFactory
{
/// <summary>Starts one fixed deadline; the operation owns the returned source.</summary>
/// <param name="timeout">The duration until cancellation.</param>
/// <returns>The operation-owned cancellation source.</returns>
public CancellationTokenSource Create(TimeSpan timeout);
}
19 changes: 17 additions & 2 deletions src/OpenCode.Sdk/Internal/PersistentPtyInputFrame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,24 @@ public static byte[] Encode(byte type, long cols, long rows, ReadOnlySpan<byte>

var frame = new byte[HeaderLength + data.Length];
frame[0] = type;
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(1, 2), (ushort)cols);
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(3, 2), (ushort)rows);
WriteViewport(frame, cols, rows);
data.CopyTo(frame.AsSpan(HeaderLength));
return frame;
}

/// <summary>Writes a validated viewport into an already owned frame under send serialization.</summary>
/// <param name="frame">The complete frame buffer, including its five-byte header.</param>
/// <param name="cols">The viewport's column count.</param>
/// <param name="rows">The viewport's row count.</param>
public static void WriteViewport(byte[] frame, long cols, long rows)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentOutOfRangeException.ThrowIfLessThan(cols, 1);
ArgumentOutOfRangeException.ThrowIfGreaterThan(cols, ushort.MaxValue);
ArgumentOutOfRangeException.ThrowIfLessThan(rows, 1);
ArgumentOutOfRangeException.ThrowIfGreaterThan(rows, ushort.MaxValue);

BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(1, 2), (ushort)cols);
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(3, 2), (ushort)rows);
}
}
26 changes: 26 additions & 0 deletions src/OpenCode.Sdk/Internal/TerminalCancellationSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace OpenCode.Sdk.Internal;

/// <summary>Owns terminal cancellation without blocking a worker while callbacks are scheduled.</summary>
internal sealed class TerminalCancellationSource : IDisposable
{
private readonly CancellationTokenSource _source = new();

/// <summary>Gets the token observed by owned I/O or send admission.</summary>
public CancellationToken Token => _source.Token;

/// <summary>Requests cancellation and observes all callbacks before completion.</summary>
public Task CancelAsync()
{
#if NET8_0_OR_GREATER
return _source.CancelAsync();
#else
// Polyfill 10.3's CancelAsync busy-waits for a queued Task.Run to start. Concurrent
// terminal shutdown can occupy every worker with that spin and starve cancellation.
// Our lifetime owner prevents source disposal until this task and owned I/O finish.
return Task.Run(_source.Cancel, CancellationToken.None);
#endif
}

/// <summary>Releases the source after cancellation callbacks and owned I/O have settled.</summary>
public void Dispose() => _source.Dispose();
}
8 changes: 8 additions & 0 deletions src/OpenCode.Sdk/Internal/TerminalConnectionOutcome.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace OpenCode.Sdk.Internal;

/// <summary>The immutable first connection outcome shared by readers and writers.</summary>
internal sealed record TerminalConnectionOutcome(Exception? Failure)
{
/// <summary>Gets the failure a send observes after this connection ended.</summary>
public Exception SendFailure => Failure ?? new OpenCodeTransportException("The opencode PTY WebSocket connection has ended.");
}
17 changes: 17 additions & 0 deletions src/OpenCode.Sdk/Internal/TerminalDeadlineFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using OpenCode.Sdk.Internal.Abstractions;

namespace OpenCode.Sdk.Internal;

/// <summary>Starts cancellation timers using the platform timer implementation.</summary>
internal sealed class TerminalDeadlineFactory : ITerminalDeadlineFactory
{
private TerminalDeadlineFactory()
{
}

/// <summary>Gets the shared timer factory.</summary>
public static TerminalDeadlineFactory Instance { get; } = new();

/// <inheritdoc />
public CancellationTokenSource Create(TimeSpan timeout) => new(timeout);
}
Loading