diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0b13894..d62772f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,8 +23,8 @@ Each released version links straight to its GitHub Release tag.
`EventRpc`, tried after the declared literal tags and before the unknown carrier, and
`UnknownEvent` refuses an `rpc.*` tag rather than absorbing it. Four operations arrived —
`v2.plugin.awaitActivation`, `v2.plugin.check`, and `v2.plugin.update` as
- `PluginsClient.PostAwaitActivationAsync`, `PostCheckAsync`, and `PostUpdateAsync`, and
- `v2.rpc.call` as `PostCallAsync` on the new `RpcClient` family. Upstream also removed and
+ `PluginsClient.AwaitPluginActivationAsync`, `CheckPluginUpdatesAsync`, and `UpdatePluginsAsync`,
+ and `v2.rpc.call` as `CallAsync` on the new `RpcClient` family. Upstream also removed and
reshaped the plugin types, which nightly consumers will meet as compile breaks: the
`plugin.added` event arm is gone, and `PluginAdded` and `PluginAddedData` with it; `IPluginInfo`
and its `PluginInfoActive`, `PluginInfoFailed`, and `UnknownPluginInfo` variants are replaced by
@@ -63,6 +63,11 @@ Each released version links straight to its GitHub Release tag.
### 🛠️ General
+- **Breaking (nightly API):** `RpcClient.PostCallAsync` is now `CallAsync`;
+ `PluginsClient.PostAwaitActivationAsync`, `PostCheckAsync`, and `PostUpdateAsync` are now
+ `AwaitPluginActivationAsync`, `CheckPluginUpdatesAsync`, and `UpdatePluginsAsync`. Their
+ `OpenCodeRoutes` members use the corresponding names without `Async`; request/response types and
+ wire behavior are unchanged.
- **Target frameworks:** `netstandard2.0`, `net472`, `net8.0`, `net9.0`, `net10.0` — for both
packages. The suite runs on `net472` on Windows, real-process launcher tests included, and on
`net8.0`/`net9.0`/`net10.0` on all three OSes. `netstandard2.0` is a consumption target rather
diff --git a/src/OpenCode.Sdk/OpenCodeRoutes.cs b/src/OpenCode.Sdk/OpenCodeRoutes.cs
index 84d062db..b78470ac 100644
--- a/src/OpenCode.Sdk/OpenCodeRoutes.cs
+++ b/src/OpenCode.Sdk/OpenCodeRoutes.cs
@@ -1186,17 +1186,17 @@ public static string RemovePersistentPty(string ptyId)
public static class Plugins
{
///
- /// The 'GET /api/plugin' route template.
+ /// The 'POST /api/plugin/await-activation' route template.
///
- public const string ListPluginsTemplate = "/api/plugin";
+ public const string AwaitPluginActivationTemplate = "/api/plugin/await-activation";
///
- /// Builds the '/api/plugin' route.
+ /// Builds the '/api/plugin/await-activation' route.
///
/// The request shaping the query.
/// The escaped route.
- public static string ListPlugins(PluginListRequest? request = null)
+ public static string AwaitPluginActivation(PluginAwaitActivationPostRequest? request = null)
{
- var path = "/api/plugin";
+ var path = "/api/plugin/await-activation";
if (request is null)
{
return path;
@@ -1208,17 +1208,17 @@ public static string ListPlugins(PluginListRequest? request = null)
}
///
- /// The 'POST /api/plugin/await-activation' route template.
+ /// The 'POST /api/plugin/check' route template.
///
- public const string PostAwaitActivationTemplate = "/api/plugin/await-activation";
+ public const string CheckPluginUpdatesTemplate = "/api/plugin/check";
///
- /// Builds the '/api/plugin/await-activation' route.
+ /// Builds the '/api/plugin/check' route.
///
/// The request shaping the query.
/// The escaped route.
- public static string PostAwaitActivation(PluginAwaitActivationPostRequest? request = null)
+ public static string CheckPluginUpdates(PluginCheckPostRequest? request = null)
{
- var path = "/api/plugin/await-activation";
+ var path = "/api/plugin/check";
if (request is null)
{
return path;
@@ -1230,17 +1230,17 @@ public static string PostAwaitActivation(PluginAwaitActivationPostRequest? reque
}
///
- /// The 'POST /api/plugin/check' route template.
+ /// The 'GET /api/plugin' route template.
///
- public const string PostCheckTemplate = "/api/plugin/check";
+ public const string ListPluginsTemplate = "/api/plugin";
///
- /// Builds the '/api/plugin/check' route.
+ /// Builds the '/api/plugin' route.
///
/// The request shaping the query.
/// The escaped route.
- public static string PostCheck(PluginCheckPostRequest? request = null)
+ public static string ListPlugins(PluginListRequest? request = null)
{
- var path = "/api/plugin/check";
+ var path = "/api/plugin";
if (request is null)
{
return path;
@@ -1254,13 +1254,13 @@ public static string PostCheck(PluginCheckPostRequest? request = null)
///
/// The 'POST /api/plugin/update' route template.
///
- public const string PostUpdateTemplate = "/api/plugin/update";
+ public const string UpdatePluginsTemplate = "/api/plugin/update";
///
/// Builds the '/api/plugin/update' route.
///
/// The request shaping the query.
/// The escaped route.
- public static string PostUpdate(PluginUpdatePostRequest? request = null)
+ public static string UpdatePlugins(PluginUpdatePostRequest? request = null)
{
var path = "/api/plugin/update";
if (request is null)
@@ -1585,7 +1585,7 @@ public static class Rpc
///
/// The 'POST /api/rpc/{rpcID}/{method}' route template.
///
- public const string PostCallTemplate = "/api/rpc/{rpcID}/{method}";
+ public const string CallTemplate = "/api/rpc/{rpcID}/{method}";
///
/// Builds the '/api/rpc/{rpcID}/{method}' route.
///
@@ -1593,7 +1593,7 @@ public static class Rpc
/// The 'method' route value.
/// The request shaping the query.
/// The escaped route.
- public static string PostCall(string rpcId, string method, RpcCallPostRequest? request = null)
+ public static string Call(string rpcId, string method, RpcCallPostRequest? request = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rpcId);
if (rpcId is "." or "..")
diff --git a/src/OpenCode.Sdk/Plugins/PluginsClient.cs b/src/OpenCode.Sdk/Plugins/PluginsClient.cs
index 88ac3b81..10be5bd3 100644
--- a/src/OpenCode.Sdk/Plugins/PluginsClient.cs
+++ b/src/OpenCode.Sdk/Plugins/PluginsClient.cs
@@ -29,45 +29,45 @@ protected PluginsClient()
private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("PluginsClient", "Pipeline");
///
- /// List plugins. Retrieve enabled server plugins and their current status.
+ /// Wait for plugin activation. Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.
///
/// The request shaping the query.
/// The per-call options.
/// The cancellation token.
- /// The 'PluginListResponse' envelope.
+ /// The 'PluginAwaitActivationPostResponse' envelope.
/// The API returned an error status (declared: 400, 401) and NoThrow was not selected.
/// The server could not be reached or returned a malformed success body.
- public virtual Task ListPluginsAsync(PluginListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default)
+ public virtual Task AwaitPluginActivationAsync(PluginAwaitActivationPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default)
{
- return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Plugins.ListPlugins(request), PluginListResponseAdapter.Instance, requestOptions, cancellationToken);
+ return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Plugins.AwaitPluginActivation(request), PluginAwaitActivationPostResponseAdapter.Instance, requestOptions, cancellationToken);
}
///
- /// Wait for plugin activation. Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.
+ /// Check plugin updates. Check one or all package plugins for available updates.
///
- /// The request shaping the query.
+ /// The request body; an empty body is sent when omitted.
/// The per-call options.
/// The cancellation token.
- /// The 'PluginAwaitActivationPostResponse' envelope.
+ /// The 'PluginCheckPostResponse' envelope.
/// The API returned an error status (declared: 400, 401) and NoThrow was not selected.
/// The server could not be reached or returned a malformed success body.
- public virtual Task PostAwaitActivationAsync(PluginAwaitActivationPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default)
+ public virtual Task CheckPluginUpdatesAsync(PluginCheckPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default)
{
- return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Plugins.PostAwaitActivation(request), PluginAwaitActivationPostResponseAdapter.Instance, requestOptions, cancellationToken);
+ return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Plugins.CheckPluginUpdates(request), request ?? EmptyPluginCheckPostRequest, OpenCodeJsonContext.Default.PluginCheckPostRequest, PluginCheckPostResponseAdapter.Instance, requestOptions, cancellationToken);
}
///
- /// Check plugin updates. Check one or all package plugins for available updates.
+ /// List plugins. Retrieve enabled server plugins and their current status.
///
- /// The request body; an empty body is sent when omitted.
+ /// The request shaping the query.
/// The per-call options.
/// The cancellation token.
- /// The 'PluginCheckPostResponse' envelope.
+ /// The 'PluginListResponse' envelope.
/// The API returned an error status (declared: 400, 401) and NoThrow was not selected.
/// The server could not be reached or returned a malformed success body.
- public virtual Task PostCheckAsync(PluginCheckPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default)
+ public virtual Task ListPluginsAsync(PluginListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default)
{
- return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Plugins.PostCheck(request), request ?? EmptyPluginCheckPostRequest, OpenCodeJsonContext.Default.PluginCheckPostRequest, PluginCheckPostResponseAdapter.Instance, requestOptions, cancellationToken);
+ return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Plugins.ListPlugins(request), PluginListResponseAdapter.Instance, requestOptions, cancellationToken);
}
///
@@ -79,9 +79,9 @@ public virtual Task PostCheckAsync(PluginCheckPostReque
/// The 'PluginUpdatePostResponse' envelope.
/// The API returned an error status (declared: 400, 401, 503) and NoThrow was not selected.
/// The server could not be reached or returned a malformed success body.
- public virtual Task PostUpdateAsync(PluginUpdatePostRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default)
+ public virtual Task UpdatePluginsAsync(PluginUpdatePostRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
- return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Plugins.PostUpdate(request), request, OpenCodeJsonContext.Default.PluginUpdatePostRequest, PluginUpdatePostResponseAdapter.Instance, requestOptions, cancellationToken);
+ return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Plugins.UpdatePlugins(request), request, OpenCodeJsonContext.Default.PluginUpdatePostRequest, PluginUpdatePostResponseAdapter.Instance, requestOptions, cancellationToken);
}
}
diff --git a/src/OpenCode.Sdk/Rpc/RpcClient.cs b/src/OpenCode.Sdk/Rpc/RpcClient.cs
index 9e8f1938..cdc354a6 100644
--- a/src/OpenCode.Sdk/Rpc/RpcClient.cs
+++ b/src/OpenCode.Sdk/Rpc/RpcClient.cs
@@ -39,10 +39,10 @@ protected RpcClient()
/// The 'RpcCallPostResponse' envelope.
/// The API returned an error status (declared: 400, 401, 500) and NoThrow was not selected.
/// The server could not be reached or returned a malformed success body.
- public virtual Task PostCallAsync(string rpcId, string method, RpcCallPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default)
+ public virtual Task CallAsync(string rpcId, string method, RpcCallPostRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(rpcId);
ArgumentException.ThrowIfNullOrEmpty(method);
- return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Rpc.PostCall(rpcId, method, request), request ?? EmptyRpcCallPostRequest, OpenCodeJsonContext.Default.RpcCallPostRequest, RpcCallPostResponseAdapter.Instance, requestOptions, cancellationToken);
+ return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Rpc.Call(rpcId, method, request), request ?? EmptyRpcCallPostRequest, OpenCodeJsonContext.Default.RpcCallPostRequest, RpcCallPostResponseAdapter.Instance, requestOptions, cancellationToken);
}
}
diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js
new file mode 100644
index 00000000..bf53ce7d
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js
@@ -0,0 +1,93 @@
+const port = Number(process.env.OPENCODE_SDK_TEST_TREE_PORT);
+const nonce = process.env.OPENCODE_SDK_TEST_TREE_NONCE;
+const mode = process.env.OPENCODE_SDK_TEST_TREE_MODE;
+
+if (!Number.isInteger(port) || port <= 0 || !nonce || !mode) {
+ throw new Error("The child-tree fixture requires its port, nonce, and mode environment.");
+}
+
+// Each peer owns its own TCP connection to the test. In particular the child does not
+// inherit a parent-owned pipe or a timer: killing only the root leaves this child alive.
+async function connectLease() {
+ const net = await import("node:net");
+ const socket = net.createConnection({
+ host: "127.0.0.1",
+ port: Number(process.env.OPENCODE_SDK_TEST_TREE_PORT),
+ });
+ let finishAcknowledgement;
+ const acknowledged = new Promise((resolve) => {
+ finishAcknowledgement = resolve;
+ let response = "";
+ socket.setEncoding("utf8");
+ socket.on("data", (chunk) => {
+ response += chunk;
+ const newline = response.indexOf("\n");
+ if (newline < 0) return;
+ resolve(response.slice(0, newline).trim() === "ACK");
+ });
+ });
+ const released = new Promise((resolve) => {
+ const release = () => {
+ finishAcknowledgement(false);
+ resolve();
+ };
+ socket.once("end", release);
+ socket.once("close", release);
+ socket.once("error", release);
+ });
+ await new Promise((resolve, reject) => {
+ socket.once("connect", resolve);
+ socket.once("error", reject);
+ });
+ return { socket, acknowledged, released };
+}
+
+async function runChild() {
+ const lease = await connectLease();
+ try {
+ lease.socket.write(`${JSON.stringify({
+ nonce: process.env.OPENCODE_SDK_TEST_TREE_NONCE,
+ role: "child",
+ pid: process.pid,
+ })}\n`);
+ if (!await lease.acknowledged) {
+ throw new Error("The child-tree lease ended without a valid acknowledgement.");
+ }
+ await lease.released;
+ } finally {
+ lease.socket.destroy();
+ }
+}
+
+// Establish the root connection first so the test can assign both roles without a
+// connection-routing framework. The child cannot connect until after this succeeds.
+const lease = await connectLease();
+let child;
+try {
+ child = Bun.spawn(
+ [process.execPath, "-e", `${connectLease.toString()}; await (${runChild.toString()})();`],
+ { stdin: "ignore", stdout: "ignore", stderr: "ignore" },
+ );
+ lease.socket.write(`${JSON.stringify({
+ nonce, role: "root", pid: process.pid, childPid: child.pid,
+ })}\n`);
+ if (!await lease.acknowledged) {
+ throw new Error("The child-tree lease ended without a valid acknowledgement.");
+ }
+
+ if (mode === "invalid-line") {
+ console.log("hello");
+ } else if (mode !== "silent") {
+ throw new Error(`Unknown child-tree fixture mode '${mode}'.`);
+ }
+
+ await lease.released;
+} finally {
+ lease.socket.destroy();
+ // This is the root's direct, owned Bun child. Pre-ACK rejection must reap it even
+ // while the independently held child lease is still open on the test side.
+ if (child) {
+ if (child.exitCode === null) child.kill();
+ await child.exited;
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj
index fcd23909..7d22a167 100644
--- a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj
+++ b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj
@@ -46,6 +46,7 @@
+
diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs
index 019c9c97..5f7d903e 100644
--- a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs
+++ b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs
@@ -1,3 +1,4 @@
+using OpenCode.Sdk.Tests.Support;
using OpenCode.Sdk.TestSupport;
using Testably.Abstractions;
@@ -81,6 +82,7 @@ public async Task DisposeAsync_Should_End_The_Server_Through_The_Stdin_Lease(Can
using var _ = runRoot;
var processId = server.ProcessId;
+ await server.DisposeAsync();
await server.DisposeAsync();
await Assert.That(IsProcessRunning(processId)).IsFalse();
@@ -121,29 +123,101 @@ public async Task StartAsync_Should_Refuse_A_Server_That_Exits_Before_Readiness(
[Timeout(120_000)]
public async Task StartAsync_Should_Refuse_A_Server_That_Never_Reports_Readiness(CancellationToken cancellationToken)
{
- var failure = await Assert.That(async () => await OpenCodeServer.StartAsync(
- new OpenCodeServerOptions
- {
- Command = ["bun", "-e", "setTimeout(() => {}, 120000)"],
- ReadinessTimeout = TimeSpan.FromSeconds(2),
- },
- cancellationToken)).Throws();
+ await using var scenario = ServerStartupTreeScenario.CreateSilent(
+ TimeSpan.FromSeconds(15));
+ await scenario.StartAndObserveAsync(cancellationToken);
+
+ var failure = await Assert.That(
+ async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws();
await Assert.That(failure!.Message).Contains("did not report readiness");
+ // Take both immediate observations before any diagnostic I/O or fallback cleanup.
+ var rootExited = scenario.RootProcess.HasExited;
+ var childExited = scenario.ChildProcess.HasExited;
+ var evidence = rootExited && childExited ? string.Empty : await scenario.DescribeProcessesAsync();
+ await Assert.That(rootExited).IsTrue().Because(evidence);
+ await Assert.That(childExited).IsTrue().Because(evidence);
}
[Test]
[Timeout(120_000)]
public async Task StartAsync_Should_Refuse_A_Non_Contract_First_Line(CancellationToken cancellationToken)
{
- var failure = await Assert.That(async () => await OpenCodeServer.StartAsync(
- new OpenCodeServerOptions
- {
- Command = ["bun", "-e", "console.log('hello'); setTimeout(() => {}, 120000)"],
- },
- cancellationToken)).Throws();
+ await using var scenario = ServerStartupTreeScenario.CreateInvalidLine();
+ await scenario.StartAndObserveAsync(cancellationToken);
+
+ var failure = await Assert.That(
+ async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws();
await Assert.That(failure!.Message).Contains("readiness contract");
+ // Take both immediate observations before any diagnostic I/O or fallback cleanup.
+ var rootExited = scenario.RootProcess.HasExited;
+ var childExited = scenario.ChildProcess.HasExited;
+ var evidence = rootExited && childExited ? string.Empty : await scenario.DescribeProcessesAsync();
+ await Assert.That(rootExited).IsTrue().Because(evidence);
+ await Assert.That(childExited).IsTrue().Because(evidence);
+ }
+
+ [Test]
+ [Timeout(120_000)]
+ public async Task StartupTreeScenario_Should_Clean_Observed_Tree_When_Handshake_Fails(
+ CancellationToken cancellationToken)
+ {
+ await using var scenario = ServerStartupTreeScenario.CreateHandshakeFailure();
+ InvalidOperationException? cleanupFailure = null;
+ try
+ {
+ var observationFailure = await Assert.That(
+ async () => await scenario.StartAndObserveAsync(cancellationToken))
+ .Throws();
+ _ = await Assert.That(
+ async () => _ = await scenario.WaitForStartupAsync(cancellationToken))
+ .Throws();
+
+ await Assert.That(scenario.RootProcess.HasExited).IsTrue();
+ await Assert.That(scenario.ChildProcess.HasExited).IsTrue();
+
+ await Assert.That(observationFailure!.Message)
+ .Contains("intentionally rejected for cleanup verification");
+ }
+ finally
+ {
+ try
+ {
+ await scenario.DisposeAsync();
+ }
+ catch (InvalidOperationException exception)
+ {
+ cleanupFailure = exception;
+ }
+ }
+
+ await Assert.That(cleanupFailure).IsNotNull();
+ await Assert.That(cleanupFailure!.Message)
+ .Contains("intentionally rejected for cleanup verification");
+ }
+
+ [Test]
+ [Timeout(120_000)]
+ public async Task StartupTreeScenario_Should_Release_Each_Owned_Lease_Independently(
+ CancellationToken cancellationToken)
+ {
+ await using var scenario = ServerStartupTreeScenario.CreateSilent(TimeSpan.FromMinutes(2));
+ await scenario.StartAndObserveAsync(cancellationToken);
+ await Assert.That(scenario.RootProcess.HasExited).IsFalse();
+ await Assert.That(scenario.ChildProcess.HasExited).IsFalse();
+
+ // Exercise cooperative cleanup while startup is still waiting. This is fixture evidence,
+ // not launcher-reaping evidence: the latter tests keep both leases open until assertions.
+ scenario.ReleaseChildLease();
+ await scenario.ChildProcess.WaitForExitAsync(cancellationToken);
+ await Assert.That(scenario.ChildProcess.HasExited).IsTrue();
+ await Assert.That(scenario.RootProcess.HasExited).IsFalse();
+
+ scenario.ReleaseRootLease();
+ _ = await Assert.That(async () => _ = await scenario.WaitForStartupAsync(cancellationToken))
+ .Throws();
+ await Assert.That(scenario.RootProcess.HasExited).IsTrue();
}
[Test]
@@ -164,17 +238,20 @@ public async Task StartAsync_Should_Refuse_A_Missing_Executable(CancellationToke
[Timeout(120_000)]
public async Task StartAsync_Should_Surface_Caller_Cancellation(CancellationToken cancellationToken)
{
- // Linked to the test's own injected token so the manufactured 200ms caller-cancellation
- // still composes with whatever the [Timeout] attribute (or an external test-run
- // cancellation) also asks for, rather than replacing it outright.
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- cancellation.CancelAfter(TimeSpan.FromMilliseconds(200));
+ await using var scenario = ServerStartupTreeScenario.CreateSilent(
+ TimeSpan.FromMinutes(2));
+ await scenario.StartAndObserveAsync(cancellation.Token);
- _ = await Assert.That(async () => await OpenCodeServer.StartAsync(
- new OpenCodeServerOptions
- {
- Command = ["bun", "-e", "setTimeout(() => {}, 120000)"],
- },
- cancellation.Token)).Throws();
+ await cancellation.CancelAsync();
+
+ _ = await Assert.That(
+ async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws();
+ // Take both immediate observations before any diagnostic I/O or fallback cleanup.
+ var rootExited = scenario.RootProcess.HasExited;
+ var childExited = scenario.ChildProcess.HasExited;
+ var evidence = rootExited && childExited ? string.Empty : await scenario.DescribeProcessesAsync();
+ await Assert.That(rootExited).IsTrue().Because(evidence);
+ await Assert.That(childExited).IsTrue().Because(evidence);
}
}
diff --git a/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs
index 8b614d95..4b814ac6 100644
--- a/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs
+++ b/tests/OpenCode.Sdk.Tests/Plugins/PluginsClientLiveTests.cs
@@ -35,12 +35,12 @@ public sealed class PluginsClientLiveTests(PinnedOpenCodeServerFixture server)
[Test]
[Timeout(60_000)]
- public async Task PostAwaitActivationAsync_Should_Answer_204_Once_Activation_Settles(
+ public async Task AwaitPluginActivationAsync_Should_Answer_204_Once_Activation_Settles(
CancellationToken cancellationToken)
{
using var client = server.CreateClient();
- var settled = await client.Plugins.PostAwaitActivationAsync(cancellationToken: cancellationToken);
+ var settled = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken);
await Assert.That(settled.Status).IsEqualTo(204);
await Assert.That(settled.IsError).IsFalse();
@@ -57,7 +57,7 @@ public async Task ListPluginsAsync_Should_Report_The_Builtin_Inventory(Cancellat
// plugin.list reads the inventory as it stands (handlers/plugin.ts:11-13) while check and
// update settle activation first (:20, :60); a cold server lists nothing until the builtins
// activate, so this test settles activation itself before it asserts the inventory.
- _ = await client.Plugins.PostAwaitActivationAsync(cancellationToken: cancellationToken);
+ _ = await client.Plugins.AwaitPluginActivationAsync(cancellationToken: cancellationToken);
var listed = await client.Plugins.ListPluginsAsync(cancellationToken: cancellationToken);
@@ -78,12 +78,12 @@ public async Task ListPluginsAsync_Should_Report_The_Builtin_Inventory(Cancellat
[Test]
[Timeout(60_000)]
- public async Task PostCheckAsync_Should_Answer_The_Inventory_When_No_Target_Is_Named(
+ public async Task CheckPluginUpdatesAsync_Should_Answer_The_Inventory_When_No_Target_Is_Named(
CancellationToken cancellationToken)
{
using var client = server.CreateClient();
- var inventory = await client.Plugins.PostCheckAsync(cancellationToken: cancellationToken);
+ var inventory = await client.Plugins.CheckPluginUpdatesAsync(cancellationToken: cancellationToken);
await Assert.That(inventory.Status).IsEqualTo(200);
await Assert.That(inventory.IsError).IsFalse();
@@ -101,11 +101,12 @@ public async Task PostCheckAsync_Should_Answer_The_Inventory_When_No_Target_Is_N
[Test]
[Timeout(60_000)]
- public async Task PostCheckAsync_Should_Refuse_A_Target_Outside_The_Inventory(CancellationToken cancellationToken)
+ public async Task CheckPluginUpdatesAsync_Should_Refuse_A_Target_Outside_The_Inventory(
+ CancellationToken cancellationToken)
{
using var client = server.CreateClient();
- var refused = await client.Plugins.PostCheckAsync(
+ var refused = await client.Plugins.CheckPluginUpdatesAsync(
new PluginCheckPostRequest { Target = AbsentTarget }, OpenCodeRequestOptions.NoThrow, cancellationToken);
await Assert.That(refused.Status).IsEqualTo(400);
@@ -123,11 +124,11 @@ public async Task PostCheckAsync_Should_Refuse_A_Target_Outside_The_Inventory(Ca
[Test]
[Timeout(60_000)]
- public async Task PostUpdateAsync_Should_Answer_204_When_No_Targets_Are_Named(CancellationToken cancellationToken)
+ public async Task UpdatePluginsAsync_Should_Answer_204_When_No_Targets_Are_Named(CancellationToken cancellationToken)
{
using var client = server.CreateClient();
- var updated = await client.Plugins.PostUpdateAsync(
+ var updated = await client.Plugins.UpdatePluginsAsync(
new PluginUpdatePostRequest { Targets = [] }, cancellationToken: cancellationToken);
await Assert.That(updated.Status).IsEqualTo(204);
@@ -138,11 +139,12 @@ public async Task PostUpdateAsync_Should_Answer_204_When_No_Targets_Are_Named(Ca
[Test]
[Timeout(60_000)]
- public async Task PostUpdateAsync_Should_Refuse_A_Target_Outside_The_Inventory(CancellationToken cancellationToken)
+ public async Task UpdatePluginsAsync_Should_Refuse_A_Target_Outside_The_Inventory(
+ CancellationToken cancellationToken)
{
using var client = server.CreateClient();
- var refused = await client.Plugins.PostUpdateAsync(
+ var refused = await client.Plugins.UpdatePluginsAsync(
new PluginUpdatePostRequest { Targets = [AbsentTarget] }, OpenCodeRequestOptions.NoThrow, cancellationToken);
await Assert.That(refused.Status).IsEqualTo(400);
diff --git a/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs
index 629fc324..b86be19b 100644
--- a/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs
+++ b/tests/OpenCode.Sdk.Tests/Rpc/RpcClientLiveTests.cs
@@ -29,12 +29,12 @@ public sealed class RpcClientLiveTests(PinnedOpenCodeServerFixture server)
[Test]
[Timeout(60_000)]
- public async Task PostCallAsync_Should_Answer_The_Unavailable_Arm_On_The_NoThrow_Spine_When_No_Rpc_Is_Registered(
+ public async Task CallAsync_Should_Answer_The_Unavailable_Arm_On_The_NoThrow_Spine_When_No_Rpc_Is_Registered(
CancellationToken cancellationToken)
{
using var client = server.CreateClient();
- var response = await client.Rpc.PostCallAsync(
+ var response = await client.Rpc.CallAsync(
UnregisteredRpcId, Method, CallRequest(), OpenCodeRequestOptions.NoThrow, cancellationToken);
await Assert.That(response.Status).IsEqualTo(400);
@@ -58,13 +58,13 @@ public async Task PostCallAsync_Should_Answer_The_Unavailable_Arm_On_The_NoThrow
[Test]
[Timeout(60_000)]
- public async Task PostCallAsync_Should_Throw_The_Unavailable_Arm_When_No_Rpc_Is_Registered(
+ public async Task CallAsync_Should_Throw_The_Unavailable_Arm_When_No_Rpc_Is_Registered(
CancellationToken cancellationToken)
{
using var client = server.CreateClient();
var exception = await Assert
- .That(async () => _ = await client.Rpc.PostCallAsync(
+ .That(async () => _ = await client.Rpc.CallAsync(
UnregisteredRpcId, Method, CallRequest(), cancellationToken: cancellationToken))
.Throws();
diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt
index 56e12309..25183cf0 100644
--- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt
+++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt
@@ -662,14 +662,14 @@
}
public static class Plugins
{
+ public const string AwaitPluginActivationTemplate = "/api/plugin/await-activation";
+ public const string CheckPluginUpdatesTemplate = "/api/plugin/check";
public const string ListPluginsTemplate = "/api/plugin";
- public const string PostAwaitActivationTemplate = "/api/plugin/await-activation";
- public const string PostCheckTemplate = "/api/plugin/check";
- public const string PostUpdateTemplate = "/api/plugin/update";
+ public const string UpdatePluginsTemplate = "/api/plugin/update";
+ public static string AwaitPluginActivation(OpenCode.Sdk.PluginAwaitActivationPostRequest? request = null) { }
+ public static string CheckPluginUpdates(OpenCode.Sdk.Models.PluginCheckPostRequest? request = null) { }
public static string ListPlugins(OpenCode.Sdk.PluginListRequest? request = null) { }
- public static string PostAwaitActivation(OpenCode.Sdk.PluginAwaitActivationPostRequest? request = null) { }
- public static string PostCheck(OpenCode.Sdk.Models.PluginCheckPostRequest? request = null) { }
- public static string PostUpdate(OpenCode.Sdk.Models.PluginUpdatePostRequest? request = null) { }
+ public static string UpdatePlugins(OpenCode.Sdk.Models.PluginUpdatePostRequest? request = null) { }
}
public static class Projects
{
@@ -708,8 +708,8 @@
}
public static class Rpc
{
- public const string PostCallTemplate = "/api/rpc/{rpcID}/{method}";
- public static string PostCall(string rpcId, string method, OpenCode.Sdk.Models.RpcCallPostRequest? request = null) { }
+ public const string CallTemplate = "/api/rpc/{rpcID}/{method}";
+ public static string Call(string rpcId, string method, OpenCode.Sdk.Models.RpcCallPostRequest? request = null) { }
}
public static class Server
{
@@ -1155,10 +1155,10 @@
public class PluginsClient
{
protected PluginsClient() { }
+ public virtual System.Threading.Tasks.Task AwaitPluginActivationAsync(OpenCode.Sdk.PluginAwaitActivationPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { }
+ public virtual System.Threading.Tasks.Task CheckPluginUpdatesAsync(OpenCode.Sdk.Models.PluginCheckPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { }
public virtual System.Threading.Tasks.Task ListPluginsAsync(OpenCode.Sdk.PluginListRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { }
- public virtual System.Threading.Tasks.Task PostAwaitActivationAsync(OpenCode.Sdk.PluginAwaitActivationPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { }
- public virtual System.Threading.Tasks.Task PostCheckAsync(OpenCode.Sdk.Models.PluginCheckPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { }
- public virtual System.Threading.Tasks.Task PostUpdateAsync(OpenCode.Sdk.Models.PluginUpdatePostRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { }
+ public virtual System.Threading.Tasks.Task UpdatePluginsAsync(OpenCode.Sdk.Models.PluginUpdatePostRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { }
}
public sealed class ProjectCurrentRequest : System.IEquatable
{
@@ -1358,7 +1358,7 @@
public class RpcClient
{
protected RpcClient() { }
- public virtual System.Threading.Tasks.Task PostCallAsync(string rpcId, string method, OpenCode.Sdk.Models.RpcCallPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { }
+ public virtual System.Threading.Tasks.Task CallAsync(string rpcId, string method, OpenCode.Sdk.Models.RpcCallPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { }
}
public class ServerClient
{
diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs
new file mode 100644
index 00000000..a4826922
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs
@@ -0,0 +1,116 @@
+using System.Diagnostics;
+using System.IO.Abstractions;
+using System.Net;
+using System.Net.Sockets;
+
+namespace OpenCode.Sdk.Tests.Support;
+
+///
+/// Owns independent root and child control leases, authenticated and observed live before ACK.
+/// Neither lease is released by a successful handshake or a launcher's failure result.
+///
+internal sealed class ServerStartupTreeHandshake : IDisposable
+{
+ private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(15);
+
+ private TcpListener? _listener;
+ private ServerStartupTreeLease? _child;
+ private ServerStartupTreeLease? _root;
+ private readonly IFileSystem _fileSystem;
+
+ public ServerStartupTreeHandshake(IFileSystem fileSystem) => _fileSystem = fileSystem;
+
+ public Process ChildProcess => Child.Process;
+
+ public Process RootProcess => Root.Process;
+
+ public string Nonce { get; private set; } = string.Empty;
+
+ public int Port { get; private set; }
+
+ private TcpListener Listener =>
+ _listener ?? throw new InvalidOperationException("The handshake listener has not been started.");
+
+ private ServerStartupTreeLease Child =>
+ _child ?? throw new InvalidOperationException("The child lease has not been captured.");
+
+ private ServerStartupTreeLease Root =>
+ _root ?? throw new InvalidOperationException("The root lease has not been captured.");
+
+ public void Start()
+ {
+ Nonce = Guid.NewGuid().ToString("N");
+ _listener = new TcpListener(IPAddress.Loopback, 0);
+ Listener.Start();
+ Port = ((IPEndPoint)Listener.LocalEndpoint).Port;
+ }
+
+ public async Task CaptureAndAcknowledgeAsync(bool acknowledge, CancellationToken cancellationToken)
+ {
+ using var bound = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ bound.CancelAfter(HandshakeTimeout);
+ _root = new ServerStartupTreeLease(await AcceptTcpClientAsync(bound.Token), _fileSystem);
+ try
+ {
+ var childPid = await Root.AuthenticateAsync(Nonce, "root", bound.Token);
+ _child = new ServerStartupTreeLease(await AcceptTcpClientAsync(bound.Token), _fileSystem);
+ _ = await Child.AuthenticateAsync(Nonce, "child", bound.Token);
+ if (RootProcess.Id == ChildProcess.Id || childPid != ChildProcess.Id
+ || RootProcess.HasExited || ChildProcess.HasExited)
+ {
+ throw new InvalidOperationException("The handshake did not identify the live root and its child.");
+ }
+
+ if (!acknowledge)
+ {
+ throw new InvalidOperationException(
+ "The child-tree handshake was intentionally rejected for cleanup verification.");
+ }
+
+ await Child.AcknowledgeAsync(bound.Token);
+ await Root.AcknowledgeAsync(bound.Token);
+ }
+ catch
+ {
+ // Keep the child lease open: pre-ACK rejection must prove the root reaps its own
+ // child, without child-lease fallback supplying the result.
+ Root.Release();
+ throw;
+ }
+ }
+
+ public void Stop() => _listener?.Stop();
+
+ public void ReleaseChild() => _child?.Release();
+
+ public void ReleaseRoot() => _root?.Release();
+
+ public async Task DescribeProcessesAsync(CancellationToken cancellationToken) =>
+ $"Root: {await Root.DescribeProcessAsync(cancellationToken)}; "
+ + $"child: {await Child.DescribeProcessAsync(cancellationToken)}";
+
+ public Task WaitForChildExitAsync(CancellationToken cancellationToken) =>
+ _child?.WaitForExitAsync(cancellationToken) ?? Task.CompletedTask;
+
+ public Task WaitForRootExitAsync(CancellationToken cancellationToken) =>
+ _root?.WaitForExitAsync(cancellationToken) ?? Task.CompletedTask;
+
+ public void Dispose()
+ {
+ _child?.Dispose();
+ _root?.Dispose();
+ _listener?.Stop();
+#if NET
+ _listener?.Dispose();
+#endif
+ }
+
+ private Task AcceptTcpClientAsync(CancellationToken cancellationToken)
+ {
+#if NET
+ return Listener.AcceptTcpClientAsync(cancellationToken).AsTask();
+#else
+ return Listener.AcceptTcpClientAsync().WaitAsync(cancellationToken);
+#endif
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeLease.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeLease.cs
new file mode 100644
index 00000000..6691fe3e
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeLease.cs
@@ -0,0 +1,141 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.IO.Abstractions;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+
+namespace OpenCode.Sdk.Tests.Support;
+
+///
+/// Owns one authenticated fixture connection and its process observation. Releasing the connection
+/// asks that peer to exit; teardown never signals a PID that could have been recycled.
+///
+internal sealed class ServerStartupTreeLease : IDisposable
+{
+ private readonly TcpClient _client;
+ private readonly IFileSystem _fileSystem;
+ private Process? _process;
+ private string? _observedLinuxState;
+
+ public ServerStartupTreeLease(TcpClient client, IFileSystem fileSystem)
+ {
+ _client = client;
+ _fileSystem = fileSystem;
+ }
+
+ public Process Process =>
+ _process ?? throw new InvalidOperationException("The fixture process has not been captured.");
+
+ public async Task AuthenticateAsync(string nonce, string role, CancellationToken cancellationToken)
+ {
+ using var reader = new StreamReader(
+ _client.GetStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: false,
+ bufferSize: 1024, leaveOpen: true);
+#if NET
+ var line = await reader.ReadLineAsync(cancellationToken);
+#else
+ var line = await reader.ReadLineAsync().WaitAsync(cancellationToken);
+#endif
+ using var document = JsonDocument.Parse(line
+ ?? throw new InvalidOperationException("The fixture connection ended before identity arrived."));
+ var identity = document.RootElement;
+ if (!string.Equals(identity.GetProperty("nonce").GetString(), nonce, StringComparison.Ordinal)
+ || !string.Equals(identity.GetProperty("role").GetString(), role, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException("The fixture connection did not match this scenario and role.");
+ }
+
+ _process = Process.GetProcessById(identity.GetProperty("pid").GetInt32());
+ if (Environment.OSVersion.Platform == PlatformID.Win32NT)
+ {
+ // Windows retains this native handle on the Process until disposal. Unix Process.Handle
+ // is not an OS process-identity handle and is deliberately not used as one.
+ _ = _process.Handle;
+ }
+
+ if (_process.HasExited)
+ {
+ throw new InvalidOperationException("The authenticated fixture process was not alive when captured.");
+ }
+
+ _observedLinuxState = await ReadLinuxStateAsync(cancellationToken);
+
+ return identity.TryGetProperty("childPid", out var childPid) ? childPid.GetInt32() : null;
+ }
+
+ public async Task AcknowledgeAsync(CancellationToken cancellationToken)
+ {
+ var stream = _client.GetStream();
+ var acknowledgement = Encoding.ASCII.GetBytes("ACK\n");
+#if NET
+ await stream.WriteAsync(acknowledgement, cancellationToken);
+#else
+ await stream.WriteAsync(acknowledgement, 0, acknowledgement.Length, cancellationToken);
+#endif
+ await stream.FlushAsync(cancellationToken);
+ }
+
+ public void Release() => _client.Dispose();
+
+ public async Task DescribeProcessAsync(CancellationToken cancellationToken) =>
+ $"PID {Process.Id.ToString(CultureInfo.InvariantCulture)}; observed /proc stat: {_observedLinuxState}; "
+ + $"current /proc stat: {await ReadLinuxStateAsync(cancellationToken)}";
+
+ public async Task WaitForExitAsync(CancellationToken cancellationToken)
+ {
+ if (_process is { } process)
+ {
+ // On Unix this is a conservative PID observation only: reuse can fail/timeout this
+ // check, but cannot authorize killing the replacement. The lease owns cleanup.
+ await process.WaitForExitAsync(cancellationToken);
+ }
+ }
+
+ public void Dispose()
+ {
+ _client.Dispose();
+ _process?.Dispose();
+ }
+
+ private async Task ReadLinuxStateAsync(CancellationToken cancellationToken)
+ {
+#if NET
+ if (!OperatingSystem.IsLinux())
+#else
+ if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(
+ System.Runtime.InteropServices.OSPlatform.Linux))
+#endif
+ {
+ return "unavailable on this OS";
+ }
+
+ try
+ {
+ // One bounded read, never a retry or cleanup authority. Linux stat includes state,
+ // PPID and start ticks, so failure evidence can distinguish a zombie from a live
+ // survivor or a recycled PID by comparing this with the pre-ACK snapshot.
+ using var stream = _fileSystem.File.OpenText(
+ "/proc/" + Process.Id.ToString(CultureInfo.InvariantCulture) + "/stat");
+ var buffer = new char[4096];
+#if NET
+ var count = await stream.ReadAsync(buffer.AsMemory(), cancellationToken);
+#else
+ var count = await stream.ReadAsync(buffer, 0, buffer.Length).WaitAsync(cancellationToken);
+#endif
+ return new string(buffer, 0, count).Trim();
+ }
+ catch (IOException exception)
+ {
+ return exception.Message;
+ }
+ catch (UnauthorizedAccessException exception)
+ {
+ return exception.Message;
+ }
+ catch (OperationCanceledException exception)
+ {
+ return exception.Message;
+ }
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs
new file mode 100644
index 00000000..eb6de213
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs
@@ -0,0 +1,225 @@
+using System.Diagnostics;
+using System.Globalization;
+using System.Runtime.ExceptionServices;
+using OpenCode.Sdk.TestSupport;
+using Testably.Abstractions;
+
+namespace OpenCode.Sdk.Tests.Support;
+
+///
+/// Starts the launcher against a cooperating Bun process tree and owns the startup and bounded
+/// cleanup around the handshake's independently owned control leases.
+///
+internal sealed class ServerStartupTreeScenario : IAsyncDisposable
+{
+ private const string InvalidLineMode = "invalid-line";
+ private const string SilentMode = "silent";
+
+ private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(10);
+
+ private readonly ServerStartupTreeHandshake _handshake = new(new RealFileSystem());
+ private readonly string _mode;
+ private readonly TimeSpan _readinessTimeout;
+ private readonly bool _acknowledge;
+ private Task? _startup;
+ private Task? _observation;
+ private CancellationTokenSource? _startupCancellation;
+ private readonly List _cleanupFailures = [];
+ private string? _diagnosticEvidence;
+ private int _disposed;
+
+ private ServerStartupTreeScenario(
+ string mode,
+ TimeSpan readinessTimeout,
+ bool acknowledge)
+ {
+ _mode = mode;
+ _readinessTimeout = readinessTimeout;
+ _acknowledge = acknowledge;
+ }
+
+ public Process ChildProcess => _handshake.ChildProcess;
+
+ public Process RootProcess => _handshake.RootProcess;
+
+ ///
+ /// Composes inert scenario state. Process/network work starts through StartAndObserveAsync.
+ ///
+ public static ServerStartupTreeScenario CreateInvalidLine() =>
+ new(InvalidLineMode, TimeSpan.FromMinutes(2), acknowledge: true);
+
+ public static ServerStartupTreeScenario CreateHandshakeFailure() =>
+ new(SilentMode, TimeSpan.FromMinutes(2), acknowledge: false);
+
+ public static ServerStartupTreeScenario CreateSilent(TimeSpan readinessTimeout) =>
+ new(SilentMode, readinessTimeout, acknowledge: true);
+
+ public async Task StartAndObserveAsync(CancellationToken cancellationToken)
+ {
+ _startupCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ _handshake.Start();
+ _observation = _handshake.CaptureAndAcknowledgeAsync(_acknowledge, _startupCancellation.Token);
+ _startup = OpenCodeServer.StartAsync(
+ new OpenCodeServerOptions
+ {
+ Command = ["bun", "-e", new FixtureLoader().LoadText("Server.child-tree.js")],
+ Environment = new Dictionary(StringComparer.Ordinal)
+ {
+ ["OPENCODE_SDK_TEST_TREE_PORT"] = _handshake.Port.ToString(CultureInfo.InvariantCulture),
+ ["OPENCODE_SDK_TEST_TREE_NONCE"] = _handshake.Nonce,
+ ["OPENCODE_SDK_TEST_TREE_MODE"] = _mode,
+ },
+ ReadinessTimeout = _readinessTimeout,
+ },
+ _startupCancellation.Token);
+ var observation = _observation;
+ await observation.WaitAsync(cancellationToken);
+ }
+
+ public void ReleaseChildLease() => _handshake.ReleaseChild();
+
+ public void ReleaseRootLease() => _handshake.ReleaseRoot();
+
+ public async Task DescribeProcessesAsync()
+ {
+ using var diagnosticBound = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ _diagnosticEvidence = await _handshake.DescribeProcessesAsync(diagnosticBound.Token);
+ return _diagnosticEvidence;
+ }
+
+ public async Task WaitForStartupAsync(CancellationToken cancellationToken)
+ {
+ var startup = _startup ?? throw new InvalidOperationException("The scenario has not been started.");
+ return await startup.WaitAsync(cancellationToken);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) is 1)
+ {
+ return;
+ }
+
+ using var cleanup = new CancellationTokenSource(CleanupTimeout);
+ try
+ {
+ // Close both owned connections even if pending observation failed. Releasing these
+ // leases is fallback cleanup and only happens after the test's immediate assertions.
+ CaptureFailure(_handshake.ReleaseChild);
+ CaptureFailure(_handshake.ReleaseRoot);
+ if (_startupCancellation is { } startupCancellation)
+ {
+ await CaptureFailureAsync(startupCancellation.CancelAsync);
+ }
+
+ CaptureFailure(_handshake.Stop);
+ await CaptureFailureAsync(() => AwaitObservationCleanupAsync(cleanup.Token));
+ await CaptureFailureAsync(
+ () => AwaitStartupCleanupAsync(cleanup.Token));
+ await CaptureFailureAsync(
+ () => _handshake.WaitForChildExitAsync(cleanup.Token));
+ await CaptureFailureAsync(
+ () => _handshake.WaitForRootExitAsync(cleanup.Token));
+ }
+ finally
+ {
+ try
+ {
+ _handshake.Dispose();
+ }
+ catch (Exception exception)
+ {
+ _cleanupFailures.Add(exception);
+ }
+
+ try
+ {
+ _startupCancellation?.Dispose();
+ }
+ catch (Exception exception)
+ {
+ _cleanupFailures.Add(exception);
+ }
+ }
+
+ ThrowCleanupFailures(_cleanupFailures);
+ }
+
+ private void CaptureFailure(Action cleanup)
+ {
+ try
+ {
+ cleanup();
+ }
+ catch (Exception exception)
+ {
+ _cleanupFailures.Add(exception);
+ }
+ }
+
+ private async Task CaptureFailureAsync(Func cleanup)
+ {
+ try
+ {
+ await cleanup();
+ }
+ catch (Exception exception)
+ {
+ _cleanupFailures.Add(exception);
+ }
+ }
+
+ private void ThrowCleanupFailures(List failures)
+ {
+ if (failures.Count is 0)
+ {
+ return;
+ }
+
+ if (_diagnosticEvidence is { } evidence)
+ {
+ throw new AggregateException(
+ "Startup tree cleanup failed after immediate exit observations failed. " + evidence, failures);
+ }
+
+ if (failures.Count is 1)
+ {
+ ExceptionDispatchInfo.Capture(failures[0]).Throw();
+ return;
+ }
+
+ throw new AggregateException("Multiple failures occurred while cleaning the startup tree scenario.", failures);
+ }
+
+ private async Task AwaitStartupCleanupAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ var startup = _startup;
+ if (startup is null)
+ {
+ return;
+ }
+
+ var server = await startup.WaitAsync(cancellationToken);
+ await server.DisposeAsync();
+ }
+ catch (OperationCanceledException exception) when (_startup?.IsCanceled is true)
+ {
+ _ = exception;
+ }
+ catch (OpenCodeServerException exception)
+ {
+ _ = exception;
+ }
+ }
+
+ private async Task AwaitObservationCleanupAsync(CancellationToken cancellationToken)
+ {
+ var observation = _observation;
+ if (observation is not null)
+ {
+ await observation.WaitAsync(cancellationToken);
+ }
+ }
+}
diff --git a/tests/Shared/FixtureLoader.cs b/tests/Shared/FixtureLoader.cs
index bdf6b206..e1b9b251 100644
--- a/tests/Shared/FixtureLoader.cs
+++ b/tests/Shared/FixtureLoader.cs
@@ -3,17 +3,19 @@
namespace OpenCode.Sdk.TestSupport;
-/// Loads embedded JSON fixtures from the compiling test assembly's Fixtures folder.
+/// Loads embedded fixtures from the compiling test assembly's Fixtures folder.
internal sealed class FixtureLoader
{
private readonly Assembly _assembly = typeof(FixtureLoader).Assembly;
- public string LoadJson(string name)
+ public string LoadJson(string name) => LoadText(name);
+
+ public string LoadText(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var resourceName = string.Concat(_assembly.GetName().Name, ".Fixtures.", name);
var stream = _assembly.GetManifestResourceStream(resourceName)
- ?? throw new ArgumentException($"Embedded JSON fixture '{name}' was not found.", nameof(name));
+ ?? throw new ArgumentException($"Embedded fixture '{name}' was not found.", nameof(name));
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false);
var content = Encoding.UTF8.GetString(reader.ReadBytes(checked((int)stream.Length)));
diff --git a/tools/curation.json b/tools/curation.json
index bfefae32..0fab0aca 100644
--- a/tools/curation.json
+++ b/tools/curation.json
@@ -329,6 +329,26 @@
"operationId": "v2.worktree.refresh",
"methodName": "RefreshWorktreesAsync",
"reason": "The operation's own verb is refresh; the mechanical POST prefix is transport detail, not part of the reviewed surface, exactly like v2.credential.update's own 'update' verb. The subject is the project's worktrees in the plural, matching v2.worktree.list's reviewed subject in the same client family."
+ },
+ {
+ "operationId": "v2.rpc.call",
+ "methodName": "CallAsync",
+ "reason": "The RPC container already names the domain; the operation's own verb is call, and the mechanical POST prefix is transport detail."
+ },
+ {
+ "operationId": "v2.plugin.awaitActivation",
+ "methodName": "AwaitPluginActivationAsync",
+ "reason": "The operation waits for plugin activation; the reviewed name states that completion boundary without the mechanical POST prefix."
+ },
+ {
+ "operationId": "v2.plugin.check",
+ "methodName": "CheckPluginUpdatesAsync",
+ "reason": "The operation checks plugins for updates; the reviewed name keeps the plugin subject and names the update check rather than the transport verb."
+ },
+ {
+ "operationId": "v2.plugin.update",
+ "methodName": "UpdatePluginsAsync",
+ "reason": "The operation updates plugins; the reviewed name keeps the plural plugin subject instead of the mechanical POST prefix."
}
],
"schemaNames": [