From b5cf8580b7ccecfc405a7f5f6a4d44e77e856286 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 00:43:05 +0300 Subject: [PATCH 1/4] fix(sdk): curate RPC and plugin operation names --- CHANGELOG.md | 9 ++++- src/OpenCode.Sdk/OpenCodeRoutes.cs | 38 +++++++++---------- src/OpenCode.Sdk/Plugins/PluginsClient.cs | 32 ++++++++-------- src/OpenCode.Sdk/Rpc/RpcClient.cs | 4 +- .../Plugins/PluginsClientLiveTests.cs | 24 ++++++------ .../Rpc/RpcClientLiveTests.cs | 8 ++-- .../Snapshots/PublicApi.verified.txt | 24 ++++++------ tools/curation.json | 20 ++++++++++ 8 files changed, 93 insertions(+), 66 deletions(-) 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/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/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": [ From f7e64b56318a50fcbcaae2add862a4a4f69f4752 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 01:53:45 +0300 Subject: [PATCH 2/4] test: prove launcher startup tree reaping --- .../Fixtures/Server/child-tree.js | 44 +++++ .../OpenCode.Sdk.Tests.csproj | 1 + .../OpenCodeServerLifecycleTests.cs | 49 ++--- .../Support/ServerStartupTreeHandshake.cs | 182 ++++++++++++++++++ .../Support/ServerStartupTreeScenario.cs | 140 ++++++++++++++ tests/Shared/FixtureLoader.cs | 8 +- 6 files changed, 398 insertions(+), 26 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js create mode 100644 tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs 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..5fc315fe --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js @@ -0,0 +1,44 @@ +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."); +} + +const child = Bun.spawn( + [process.execPath, "-e", "setTimeout(() => {}, 120000)"], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, +); +const net = await import("node:net"); +const socket = net.createConnection({ host: "127.0.0.1", port }); + +await new Promise((resolve, reject) => { + let response = ""; + socket.setEncoding("utf8"); + socket.once("connect", () => { + socket.write(`${JSON.stringify({ nonce, rootPid: process.pid, childPid: child.pid })}\n`); + }); + socket.on("data", (chunk) => { + response += chunk; + const newline = response.indexOf("\n"); + if (newline < 0) return; + + if (response.slice(0, newline).trim() !== "ACK") { + reject(new Error("The child-tree fixture received an invalid acknowledgement.")); + return; + } + + resolve(); + }); + socket.once("error", reject); +}); + +socket.end(); +if (mode === "invalid-line") { + console.log("hello"); +} else if (mode !== "silent") { + throw new Error(`Unknown child-tree fixture mode '${mode}'.`); +} + +await new Promise(() => {}); 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..bc576abc 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,31 @@ 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.BeginSilent( + TimeSpan.FromSeconds(15), cancellationToken); + await scenario.ObserveAndAcknowledgeAsync(cancellationToken); + + var failure = await Assert.That( + async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); await Assert.That(failure!.Message).Contains("did not report readiness"); + await Assert.That(scenario.RootProcess.HasExited).IsTrue(); + await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); } [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.BeginInvalidLine(cancellationToken); + await scenario.ObserveAndAcknowledgeAsync(cancellationToken); + + var failure = await Assert.That( + async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); await Assert.That(failure!.Message).Contains("readiness contract"); + await Assert.That(scenario.RootProcess.HasExited).IsTrue(); + await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); } [Test] @@ -164,17 +168,16 @@ 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.BeginSilent( + TimeSpan.FromMinutes(2), cancellation.Token); + await scenario.ObserveAndAcknowledgeAsync(cancellationToken); - _ = 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(); + await Assert.That(scenario.RootProcess.HasExited).IsTrue(); + await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); } } diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs new file mode 100644 index 00000000..2cbf3745 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs @@ -0,0 +1,182 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace OpenCode.Sdk.Tests.Support; + +/// +/// Owns the loopback handshake that validates a nonce and captures the Bun root and child as live +/// process handles before acknowledging the startup peer. +/// +internal sealed class ServerStartupTreeHandshake : IDisposable +{ + private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(15); + + private readonly Task _observation; + private readonly TcpListener _listener; + private Process? _childProcess; + private Process? _rootProcess; + + public ServerStartupTreeHandshake(CancellationToken cancellationToken) + { + Nonce = Guid.NewGuid().ToString("N"); + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _observation = CaptureAndAcknowledgeAsync(cancellationToken); + } + + public Process ChildProcess => + _childProcess ?? throw new InvalidOperationException("The child process has not been captured."); + + public Process? CapturedChildProcess => _childProcess; + + public Process? CapturedRootProcess => _rootProcess; + + public string Nonce { get; } + + public int Port { get; } + + public Process RootProcess => + _rootProcess ?? throw new InvalidOperationException("The root process has not been captured."); + + public async Task ObserveAndAcknowledgeAsync(CancellationToken cancellationToken) + { + var observation = _observation; + await observation.WaitAsync(cancellationToken); + } + + public async Task AwaitCleanupAsync(CancellationToken cancellationToken) + { + try + { + var observation = _observation; + await observation.WaitAsync(cancellationToken); + } + catch (OperationCanceledException exception) + { + _ = exception; + } + catch (ObjectDisposedException exception) + { + _ = exception; + } + catch (SocketException exception) + { + _ = exception; + } + } + + public void Stop() => _listener.Stop(); + + public void Dispose() + { + _childProcess?.Dispose(); + _rootProcess?.Dispose(); +#if NET + _listener.Dispose(); +#endif + } + + private async Task CaptureAndAcknowledgeAsync(CancellationToken cancellationToken) + { + using var bound = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + bound.CancelAfter(HandshakeTimeout); + using var client = await AcceptTcpClientAsync(_listener, bound.Token); + using var stream = client.GetStream(); + using var reader = new StreamReader( + stream, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, + bufferSize: 1024, + leaveOpen: true); + var line = await ReadLineAsync(reader, bound.Token) + ?? throw new InvalidOperationException("The child-tree handshake ended before process identity arrived."); + using var document = JsonDocument.Parse(line); + var root = CaptureProcess(document.RootElement, "rootPid"); + var transferred = false; + try + { + var child = CaptureProcess(document.RootElement, "childPid"); + try + { + var receivedNonce = document.RootElement.GetProperty("nonce").GetString(); + if (!string.Equals(receivedNonce, Nonce, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The child-tree handshake nonce did not match this scenario."); + } + + if (root.Id == child.Id || root.HasExited || child.HasExited) + { + throw new InvalidOperationException("The child-tree handshake did not identify two live processes."); + } + + _rootProcess = root; + _childProcess = child; + transferred = true; + await WriteAcknowledgementAsync(stream, bound.Token); + } + finally + { + if (!transferred) + { + child.Dispose(); + } + } + } + finally + { + if (!transferred) + { + root.Dispose(); + } + } + } + + private static Process CaptureProcess(JsonElement handshake, string propertyName) + { + var processId = handshake.GetProperty(propertyName).GetInt32(); + try + { + return Process.GetProcessById(processId); + } + catch (ArgumentException exception) + { + throw new InvalidOperationException( + $"The child-tree handshake process '{propertyName}' was not alive when captured.", exception); + } + } + + private static Task AcceptTcpClientAsync( + TcpListener listener, + CancellationToken cancellationToken) + { +#if NET + return listener.AcceptTcpClientAsync(cancellationToken).AsTask(); +#else + return listener.AcceptTcpClientAsync().WaitAsync(cancellationToken); +#endif + } + + private static Task ReadLineAsync(StreamReader reader, CancellationToken cancellationToken) + { +#if NET + return reader.ReadLineAsync(cancellationToken).AsTask(); +#else + return reader.ReadLineAsync().WaitAsync(cancellationToken); +#endif + } + + private static async Task WriteAcknowledgementAsync(Stream stream, CancellationToken cancellationToken) + { + 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); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs new file mode 100644 index 00000000..e11ace75 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs @@ -0,0 +1,140 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using OpenCode.Sdk.TestSupport; + +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 verified process handles. +/// +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; + private readonly Task _startup; + private readonly CancellationTokenSource _startupCancellation; + private int _disposed; + + private ServerStartupTreeScenario( + string mode, + TimeSpan readinessTimeout, + CancellationToken cancellationToken) + { + _startupCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _handshake = new ServerStartupTreeHandshake(_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); + } + + public Process ChildProcess => _handshake.ChildProcess; + + public Process RootProcess => _handshake.RootProcess; + + public static ServerStartupTreeScenario BeginInvalidLine(CancellationToken cancellationToken) => + new(InvalidLineMode, TimeSpan.FromMinutes(2), cancellationToken); + + public static ServerStartupTreeScenario BeginSilent( + TimeSpan readinessTimeout, + CancellationToken cancellationToken) => + new(SilentMode, readinessTimeout, cancellationToken); + + public Task ObserveAndAcknowledgeAsync(CancellationToken cancellationToken) => + _handshake.ObserveAndAcknowledgeAsync(cancellationToken); + + public async Task WaitForStartupAsync(CancellationToken cancellationToken) + { + var startup = _startup; + return await startup.WaitAsync(cancellationToken); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) is 1) + { + return; + } + + using var cleanup = new CancellationTokenSource(CleanupTimeout); + await _startupCancellation.CancelAsync(); + _handshake.Stop(); + try + { + await _handshake.AwaitCleanupAsync(cleanup.Token); + await AwaitStartupCleanupAsync(cleanup.Token); + await EndCapturedProcessAsync(_handshake.CapturedChildProcess, cleanup.Token); + await EndCapturedProcessAsync(_handshake.CapturedRootProcess, cleanup.Token); + } + finally + { + _handshake.Dispose(); + _startupCancellation.Dispose(); + } + } + + private async Task AwaitStartupCleanupAsync(CancellationToken cancellationToken) + { + try + { + var startup = _startup; + var server = await startup.WaitAsync(cancellationToken); + await server.DisposeAsync(); + } + catch (OperationCanceledException exception) + { + _ = exception; + } + catch (OpenCodeServerException exception) + { + _ = exception; + } + } + + private static async Task EndCapturedProcessAsync(Process? process, CancellationToken cancellationToken) + { + if (process is null || process.HasExited) + { + return; + } + + try + { + process.Kill(); + } + catch (InvalidOperationException exception) + { + _ = exception; + return; + } + catch (Win32Exception exception) + { + _ = exception; + return; + } + + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException exception) + { + _ = exception; + } + } +} 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))); From 240b3a49473000e9afd3c60f08ba573c967c9b81 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 02:17:36 +0300 Subject: [PATCH 3/4] test: harden startup tree fixture cleanup --- .../Fixtures/Server/child-tree.js | 67 ++++++----- .../OpenCodeServerLifecycleTests.cs | 39 ++++++ .../Support/ServerStartupTreeHandshake.cs | 31 ++--- .../Support/ServerStartupTreeScenario.cs | 111 ++++++++++++------ 4 files changed, 165 insertions(+), 83 deletions(-) diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js index 5fc315fe..8f5e38a1 100644 --- a/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js @@ -10,35 +10,46 @@ const child = Bun.spawn( [process.execPath, "-e", "setTimeout(() => {}, 120000)"], { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, ); -const net = await import("node:net"); -const socket = net.createConnection({ host: "127.0.0.1", port }); - -await new Promise((resolve, reject) => { - let response = ""; - socket.setEncoding("utf8"); - socket.once("connect", () => { - socket.write(`${JSON.stringify({ nonce, rootPid: process.pid, childPid: child.pid })}\n`); - }); - socket.on("data", (chunk) => { - response += chunk; - const newline = response.indexOf("\n"); - if (newline < 0) return; +let socket; +try { + const net = await import("node:net"); + socket = net.createConnection({ host: "127.0.0.1", port }); + + await new Promise((resolve, reject) => { + let response = ""; + socket.setEncoding("utf8"); + socket.once("connect", () => { + socket.write(`${JSON.stringify({ nonce, rootPid: process.pid, childPid: child.pid })}\n`); + }); + socket.on("data", (chunk) => { + response += chunk; + const newline = response.indexOf("\n"); + if (newline < 0) return; - if (response.slice(0, newline).trim() !== "ACK") { - reject(new Error("The child-tree fixture received an invalid acknowledgement.")); - return; - } + if (response.slice(0, newline).trim() !== "ACK") { + reject(new Error("The child-tree fixture received an invalid acknowledgement.")); + return; + } - resolve(); + resolve(); + }); + socket.once("end", () => reject(new Error("The child-tree handshake ended before acknowledgement."))); + socket.once("error", reject); }); - socket.once("error", reject); -}); - -socket.end(); -if (mode === "invalid-line") { - console.log("hello"); -} else if (mode !== "silent") { - throw new Error(`Unknown child-tree fixture mode '${mode}'.`); -} -await new Promise(() => {}); + socket.end(); + if (mode === "invalid-line") { + console.log("hello"); + } else if (mode !== "silent") { + throw new Error(`Unknown child-tree fixture mode '${mode}'.`); + } + + await new Promise(() => {}); +} finally { + socket?.destroy(); + if (child.exitCode === null) { + child.kill(); + } + + await child.exited; +} diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs index bc576abc..d7a13d52 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs @@ -150,6 +150,45 @@ public async Task StartAsync_Should_Refuse_A_Non_Contract_First_Line(Cancellatio await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); } + [Test] + [Timeout(120_000)] + public async Task StartupTreeScenario_Should_Clean_Observed_Tree_When_Handshake_Fails( + CancellationToken cancellationToken) + { + var scenario = ServerStartupTreeScenario.BeginHandshakeFailure(cancellationToken); + InvalidOperationException? cleanupFailure = null; + try + { + var observationFailure = await Assert.That( + async () => await scenario.ObserveAndAcknowledgeAsync(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 StartAsync_Should_Refuse_A_Missing_Executable(CancellationToken cancellationToken) diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs index 2cbf3745..6be370e9 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs @@ -14,13 +14,15 @@ internal sealed class ServerStartupTreeHandshake : IDisposable { private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(15); + private readonly bool _acknowledge; private readonly Task _observation; private readonly TcpListener _listener; private Process? _childProcess; private Process? _rootProcess; - public ServerStartupTreeHandshake(CancellationToken cancellationToken) + public ServerStartupTreeHandshake(bool acknowledge, CancellationToken cancellationToken) { + _acknowledge = acknowledge; Nonce = Guid.NewGuid().ToString("N"); _listener = new TcpListener(IPAddress.Loopback, 0); _listener.Start(); @@ -48,27 +50,6 @@ public async Task ObserveAndAcknowledgeAsync(CancellationToken cancellationToken await observation.WaitAsync(cancellationToken); } - public async Task AwaitCleanupAsync(CancellationToken cancellationToken) - { - try - { - var observation = _observation; - await observation.WaitAsync(cancellationToken); - } - catch (OperationCanceledException exception) - { - _ = exception; - } - catch (ObjectDisposedException exception) - { - _ = exception; - } - catch (SocketException exception) - { - _ = exception; - } - } - public void Stop() => _listener.Stop(); public void Dispose() @@ -116,6 +97,12 @@ private async Task CaptureAndAcknowledgeAsync(CancellationToken cancellationToke _rootProcess = root; _childProcess = child; transferred = true; + if (!_acknowledge) + { + throw new InvalidOperationException( + "The child-tree handshake was intentionally rejected for cleanup verification."); + } + await WriteAcknowledgementAsync(stream, bound.Token); } finally diff --git a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs index e11ace75..bc4bc647 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs @@ -1,6 +1,6 @@ -using System.ComponentModel; using System.Diagnostics; using System.Globalization; +using System.Runtime.ExceptionServices; using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Tests.Support; @@ -24,10 +24,11 @@ internal sealed class ServerStartupTreeScenario : IAsyncDisposable private ServerStartupTreeScenario( string mode, TimeSpan readinessTimeout, + bool acknowledge, CancellationToken cancellationToken) { _startupCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _handshake = new ServerStartupTreeHandshake(_startupCancellation.Token); + _handshake = new ServerStartupTreeHandshake(acknowledge, _startupCancellation.Token); _startup = OpenCodeServer.StartAsync( new OpenCodeServerOptions { @@ -48,12 +49,15 @@ private ServerStartupTreeScenario( public Process RootProcess => _handshake.RootProcess; public static ServerStartupTreeScenario BeginInvalidLine(CancellationToken cancellationToken) => - new(InvalidLineMode, TimeSpan.FromMinutes(2), cancellationToken); + new(InvalidLineMode, TimeSpan.FromMinutes(2), acknowledge: true, cancellationToken); + + public static ServerStartupTreeScenario BeginHandshakeFailure(CancellationToken cancellationToken) => + new(SilentMode, TimeSpan.FromMinutes(2), acknowledge: false, cancellationToken); public static ServerStartupTreeScenario BeginSilent( TimeSpan readinessTimeout, CancellationToken cancellationToken) => - new(SilentMode, readinessTimeout, cancellationToken); + new(SilentMode, readinessTimeout, acknowledge: true, cancellationToken); public Task ObserveAndAcknowledgeAsync(CancellationToken cancellationToken) => _handshake.ObserveAndAcknowledgeAsync(cancellationToken); @@ -71,70 +75,111 @@ public async ValueTask DisposeAsync() return; } + var failures = new List(); using var cleanup = new CancellationTokenSource(CleanupTimeout); - await _startupCancellation.CancelAsync(); - _handshake.Stop(); try { - await _handshake.AwaitCleanupAsync(cleanup.Token); - await AwaitStartupCleanupAsync(cleanup.Token); - await EndCapturedProcessAsync(_handshake.CapturedChildProcess, cleanup.Token); - await EndCapturedProcessAsync(_handshake.CapturedRootProcess, cleanup.Token); + await CaptureFailureAsync(_startupCancellation.CancelAsync, failures); + CaptureFailure(_handshake.Stop, failures); + await CaptureFailureAsync( + () => _handshake.ObserveAndAcknowledgeAsync(cleanup.Token), failures); + await CaptureFailureAsync( + () => AwaitStartupCleanupAsync(cleanup.Token), failures); + await CaptureFailureAsync( + () => EndCapturedProcessAsync(_handshake.CapturedChildProcess, cleanup.Token), failures); + await CaptureFailureAsync( + () => EndCapturedProcessAsync(_handshake.CapturedRootProcess, cleanup.Token), failures); } finally { - _handshake.Dispose(); - _startupCancellation.Dispose(); + try + { + _handshake.Dispose(); + } + catch (Exception exception) + { + failures.Add(exception); + } + + try + { + _startupCancellation.Dispose(); + } + catch (Exception exception) + { + failures.Add(exception); + } } + + ThrowCleanupFailures(failures); } - private async Task AwaitStartupCleanupAsync(CancellationToken cancellationToken) + private static void CaptureFailure(Action cleanup, List failures) { try { - var startup = _startup; - var server = await startup.WaitAsync(cancellationToken); - await server.DisposeAsync(); + cleanup(); } - catch (OperationCanceledException exception) + catch (Exception exception) { - _ = exception; + failures.Add(exception); } - catch (OpenCodeServerException exception) + } + + private static async Task CaptureFailureAsync(Func cleanup, List failures) + { + try { - _ = exception; + await cleanup(); + } + catch (Exception exception) + { + failures.Add(exception); } } - private static async Task EndCapturedProcessAsync(Process? process, CancellationToken cancellationToken) + private static void ThrowCleanupFailures(List failures) { - if (process is null || process.HasExited) + if (failures.Count is 0) + { + return; + } + + 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 { - process.Kill(); + var startup = _startup; + var server = await startup.WaitAsync(cancellationToken); + await server.DisposeAsync(); } - catch (InvalidOperationException exception) + catch (OperationCanceledException exception) when (_startup.IsCanceled) { _ = exception; - return; } - catch (Win32Exception exception) + catch (OpenCodeServerException exception) { _ = exception; - return; } + } - try - { - await process.WaitForExitAsync(cancellationToken); - } - catch (OperationCanceledException exception) + private static async Task EndCapturedProcessAsync(Process? process, CancellationToken cancellationToken) + { + if (process is null || process.HasExited) { - _ = exception; + return; } + + process.Kill(); + await process.WaitForExitAsync(cancellationToken); } } From 2de3fb78629bf35e456f87a18ff0eb5da1151e80 Mon Sep 17 00:00:00 2001 From: "denizirgin@gmail.com" Date: Sun, 6 Sep 2026 02:56:22 +0300 Subject: [PATCH 4/4] test: own startup tree cleanup through control leases --- .../Fixtures/Server/child-tree.js | 92 ++++++--- .../OpenCodeServerLifecycleTests.cs | 67 +++++-- .../Support/ServerStartupTreeHandshake.cs | 183 +++++++----------- .../Support/ServerStartupTreeLease.cs | 141 ++++++++++++++ .../Support/ServerStartupTreeScenario.cs | 138 ++++++++----- 5 files changed, 411 insertions(+), 210 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeLease.cs diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js index 8f5e38a1..bf53ce7d 100644 --- a/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Server/child-tree.js @@ -6,50 +6,88 @@ if (!Number.isInteger(port) || port <= 0 || !nonce || !mode) { throw new Error("The child-tree fixture requires its port, nonce, and mode environment."); } -const child = Bun.spawn( - [process.execPath, "-e", "setTimeout(() => {}, 120000)"], - { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, -); -let socket; -try { +// 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"); - socket = net.createConnection({ host: "127.0.0.1", port }); - - await new Promise((resolve, reject) => { + 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.once("connect", () => { - socket.write(`${JSON.stringify({ nonce, rootPid: process.pid, childPid: child.pid })}\n`); - }); socket.on("data", (chunk) => { response += chunk; const newline = response.indexOf("\n"); if (newline < 0) return; - - if (response.slice(0, newline).trim() !== "ACK") { - reject(new Error("The child-tree fixture received an invalid acknowledgement.")); - return; - } - - resolve(); + resolve(response.slice(0, newline).trim() === "ACK"); }); - socket.once("end", () => reject(new Error("The child-tree handshake ended before acknowledgement."))); + }); + 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."); + } - socket.end(); if (mode === "invalid-line") { console.log("hello"); } else if (mode !== "silent") { throw new Error(`Unknown child-tree fixture mode '${mode}'.`); } - await new Promise(() => {}); + await lease.released; } finally { - socket?.destroy(); - if (child.exitCode === null) { - child.kill(); + 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; } - - await child.exited; } diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs index d7a13d52..5f7d903e 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeServerLifecycleTests.cs @@ -123,31 +123,39 @@ 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) { - await using var scenario = ServerStartupTreeScenario.BeginSilent( - TimeSpan.FromSeconds(15), cancellationToken); - await scenario.ObserveAndAcknowledgeAsync(cancellationToken); + 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"); - await Assert.That(scenario.RootProcess.HasExited).IsTrue(); - await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); + // 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) { - await using var scenario = ServerStartupTreeScenario.BeginInvalidLine(cancellationToken); - await scenario.ObserveAndAcknowledgeAsync(cancellationToken); + 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"); - await Assert.That(scenario.RootProcess.HasExited).IsTrue(); - await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); + // 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] @@ -155,12 +163,12 @@ public async Task StartAsync_Should_Refuse_A_Non_Contract_First_Line(Cancellatio public async Task StartupTreeScenario_Should_Clean_Observed_Tree_When_Handshake_Fails( CancellationToken cancellationToken) { - var scenario = ServerStartupTreeScenario.BeginHandshakeFailure(cancellationToken); + await using var scenario = ServerStartupTreeScenario.CreateHandshakeFailure(); InvalidOperationException? cleanupFailure = null; try { var observationFailure = await Assert.That( - async () => await scenario.ObserveAndAcknowledgeAsync(cancellationToken)) + async () => await scenario.StartAndObserveAsync(cancellationToken)) .Throws(); _ = await Assert.That( async () => _ = await scenario.WaitForStartupAsync(cancellationToken)) @@ -189,6 +197,29 @@ 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] [Timeout(120_000)] public async Task StartAsync_Should_Refuse_A_Missing_Executable(CancellationToken cancellationToken) @@ -208,15 +239,19 @@ public async Task StartAsync_Should_Refuse_A_Missing_Executable(CancellationToke public async Task StartAsync_Should_Surface_Caller_Cancellation(CancellationToken cancellationToken) { using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - await using var scenario = ServerStartupTreeScenario.BeginSilent( - TimeSpan.FromMinutes(2), cancellation.Token); - await scenario.ObserveAndAcknowledgeAsync(cancellationToken); + await using var scenario = ServerStartupTreeScenario.CreateSilent( + TimeSpan.FromMinutes(2)); + await scenario.StartAndObserveAsync(cancellation.Token); await cancellation.CancelAsync(); _ = await Assert.That( async () => _ = await scenario.WaitForStartupAsync(cancellationToken)).Throws(); - await Assert.That(scenario.RootProcess.HasExited).IsTrue(); - await Assert.That(scenario.ChildProcess.HasExited).IsTrue(); + // 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/Support/ServerStartupTreeHandshake.cs b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs index 6be370e9..a4826922 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeHandshake.cs @@ -1,169 +1,116 @@ using System.Diagnostics; +using System.IO.Abstractions; using System.Net; using System.Net.Sockets; -using System.Text; -using System.Text.Json; namespace OpenCode.Sdk.Tests.Support; /// -/// Owns the loopback handshake that validates a nonce and captures the Bun root and child as live -/// process handles before acknowledging the startup peer. +/// 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 readonly bool _acknowledge; - private readonly Task _observation; - private readonly TcpListener _listener; - private Process? _childProcess; - private Process? _rootProcess; + private TcpListener? _listener; + private ServerStartupTreeLease? _child; + private ServerStartupTreeLease? _root; + private readonly IFileSystem _fileSystem; - public ServerStartupTreeHandshake(bool acknowledge, CancellationToken cancellationToken) - { - _acknowledge = acknowledge; - Nonce = Guid.NewGuid().ToString("N"); - _listener = new TcpListener(IPAddress.Loopback, 0); - _listener.Start(); - Port = ((IPEndPoint)_listener.LocalEndpoint).Port; - _observation = CaptureAndAcknowledgeAsync(cancellationToken); - } + public ServerStartupTreeHandshake(IFileSystem fileSystem) => _fileSystem = fileSystem; - public Process ChildProcess => - _childProcess ?? throw new InvalidOperationException("The child process has not been captured."); + public Process ChildProcess => Child.Process; - public Process? CapturedChildProcess => _childProcess; + public Process RootProcess => Root.Process; - public Process? CapturedRootProcess => _rootProcess; + public string Nonce { get; private set; } = string.Empty; - public string Nonce { get; } + public int Port { get; private set; } - public int Port { get; } + private TcpListener Listener => + _listener ?? throw new InvalidOperationException("The handshake listener has not been started."); - public Process RootProcess => - _rootProcess ?? throw new InvalidOperationException("The root process has not been captured."); + private ServerStartupTreeLease Child => + _child ?? throw new InvalidOperationException("The child lease has not been captured."); - public async Task ObserveAndAcknowledgeAsync(CancellationToken cancellationToken) - { - var observation = _observation; - await observation.WaitAsync(cancellationToken); - } - - public void Stop() => _listener.Stop(); + private ServerStartupTreeLease Root => + _root ?? throw new InvalidOperationException("The root lease has not been captured."); - public void Dispose() + public void Start() { - _childProcess?.Dispose(); - _rootProcess?.Dispose(); -#if NET - _listener.Dispose(); -#endif + Nonce = Guid.NewGuid().ToString("N"); + _listener = new TcpListener(IPAddress.Loopback, 0); + Listener.Start(); + Port = ((IPEndPoint)Listener.LocalEndpoint).Port; } - private async Task CaptureAndAcknowledgeAsync(CancellationToken cancellationToken) + public async Task CaptureAndAcknowledgeAsync(bool acknowledge, CancellationToken cancellationToken) { using var bound = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); bound.CancelAfter(HandshakeTimeout); - using var client = await AcceptTcpClientAsync(_listener, bound.Token); - using var stream = client.GetStream(); - using var reader = new StreamReader( - stream, - Encoding.UTF8, - detectEncodingFromByteOrderMarks: false, - bufferSize: 1024, - leaveOpen: true); - var line = await ReadLineAsync(reader, bound.Token) - ?? throw new InvalidOperationException("The child-tree handshake ended before process identity arrived."); - using var document = JsonDocument.Parse(line); - var root = CaptureProcess(document.RootElement, "rootPid"); - var transferred = false; + _root = new ServerStartupTreeLease(await AcceptTcpClientAsync(bound.Token), _fileSystem); try { - var child = CaptureProcess(document.RootElement, "childPid"); - try - { - var receivedNonce = document.RootElement.GetProperty("nonce").GetString(); - if (!string.Equals(receivedNonce, Nonce, StringComparison.Ordinal)) - { - throw new InvalidOperationException("The child-tree handshake nonce did not match this scenario."); - } - - if (root.Id == child.Id || root.HasExited || child.HasExited) - { - throw new InvalidOperationException("The child-tree handshake did not identify two live processes."); - } - - _rootProcess = root; - _childProcess = child; - transferred = true; - if (!_acknowledge) - { - throw new InvalidOperationException( - "The child-tree handshake was intentionally rejected for cleanup verification."); - } - - await WriteAcknowledgementAsync(stream, bound.Token); - } - finally + 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) { - if (!transferred) - { - child.Dispose(); - } + throw new InvalidOperationException("The handshake did not identify the live root and its child."); } - } - finally - { - if (!transferred) + + if (!acknowledge) { - root.Dispose(); + throw new InvalidOperationException( + "The child-tree handshake was intentionally rejected for cleanup verification."); } - } - } - private static Process CaptureProcess(JsonElement handshake, string propertyName) - { - var processId = handshake.GetProperty(propertyName).GetInt32(); - try - { - return Process.GetProcessById(processId); + await Child.AcknowledgeAsync(bound.Token); + await Root.AcknowledgeAsync(bound.Token); } - catch (ArgumentException exception) + catch { - throw new InvalidOperationException( - $"The child-tree handshake process '{propertyName}' was not alive when captured.", exception); + // 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; } } - private static Task AcceptTcpClientAsync( - TcpListener listener, - CancellationToken cancellationToken) - { -#if NET - return listener.AcceptTcpClientAsync(cancellationToken).AsTask(); -#else - return listener.AcceptTcpClientAsync().WaitAsync(cancellationToken); -#endif - } + 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)}"; - private static Task ReadLineAsync(StreamReader reader, CancellationToken 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 - return reader.ReadLineAsync(cancellationToken).AsTask(); -#else - return reader.ReadLineAsync().WaitAsync(cancellationToken); + _listener?.Dispose(); #endif } - private static async Task WriteAcknowledgementAsync(Stream stream, CancellationToken cancellationToken) + private Task AcceptTcpClientAsync(CancellationToken cancellationToken) { - var acknowledgement = Encoding.ASCII.GetBytes("ACK\n"); #if NET - await stream.WriteAsync(acknowledgement, cancellationToken); + return Listener.AcceptTcpClientAsync(cancellationToken).AsTask(); #else - await stream.WriteAsync(acknowledgement, 0, acknowledgement.Length, cancellationToken); + return Listener.AcceptTcpClientAsync().WaitAsync(cancellationToken); #endif - await stream.FlushAsync(cancellationToken); } } 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 index bc4bc647..eb6de213 100644 --- a/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs +++ b/tests/OpenCode.Sdk.Tests/Support/ServerStartupTreeScenario.cs @@ -2,12 +2,13 @@ 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 verified process handles. +/// cleanup around the handshake's independently owned control leases. /// internal sealed class ServerStartupTreeScenario : IAsyncDisposable { @@ -16,19 +17,48 @@ internal sealed class ServerStartupTreeScenario : IAsyncDisposable private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(10); - private readonly ServerStartupTreeHandshake _handshake; - private readonly Task _startup; - private readonly CancellationTokenSource _startupCancellation; + 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, - CancellationToken cancellationToken) + 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 = new ServerStartupTreeHandshake(acknowledge, _startupCancellation.Token); + _handshake.Start(); + _observation = _handshake.CaptureAndAcknowledgeAsync(_acknowledge, _startupCancellation.Token); _startup = OpenCodeServer.StartAsync( new OpenCodeServerOptions { @@ -37,34 +67,29 @@ private ServerStartupTreeScenario( { ["OPENCODE_SDK_TEST_TREE_PORT"] = _handshake.Port.ToString(CultureInfo.InvariantCulture), ["OPENCODE_SDK_TEST_TREE_NONCE"] = _handshake.Nonce, - ["OPENCODE_SDK_TEST_TREE_MODE"] = mode, + ["OPENCODE_SDK_TEST_TREE_MODE"] = _mode, }, - ReadinessTimeout = readinessTimeout, + ReadinessTimeout = _readinessTimeout, }, _startupCancellation.Token); + var observation = _observation; + await observation.WaitAsync(cancellationToken); } - public Process ChildProcess => _handshake.ChildProcess; - - public Process RootProcess => _handshake.RootProcess; - - public static ServerStartupTreeScenario BeginInvalidLine(CancellationToken cancellationToken) => - new(InvalidLineMode, TimeSpan.FromMinutes(2), acknowledge: true, cancellationToken); - - public static ServerStartupTreeScenario BeginHandshakeFailure(CancellationToken cancellationToken) => - new(SilentMode, TimeSpan.FromMinutes(2), acknowledge: false, cancellationToken); + public void ReleaseChildLease() => _handshake.ReleaseChild(); - public static ServerStartupTreeScenario BeginSilent( - TimeSpan readinessTimeout, - CancellationToken cancellationToken) => - new(SilentMode, readinessTimeout, acknowledge: true, cancellationToken); + public void ReleaseRootLease() => _handshake.ReleaseRoot(); - public Task ObserveAndAcknowledgeAsync(CancellationToken cancellationToken) => - _handshake.ObserveAndAcknowledgeAsync(cancellationToken); + 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; + var startup = _startup ?? throw new InvalidOperationException("The scenario has not been started."); return await startup.WaitAsync(cancellationToken); } @@ -75,20 +100,26 @@ public async ValueTask DisposeAsync() return; } - var failures = new List(); using var cleanup = new CancellationTokenSource(CleanupTimeout); try { - await CaptureFailureAsync(_startupCancellation.CancelAsync, failures); - CaptureFailure(_handshake.Stop, failures); - await CaptureFailureAsync( - () => _handshake.ObserveAndAcknowledgeAsync(cleanup.Token), failures); + // 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), failures); + () => AwaitStartupCleanupAsync(cleanup.Token)); await CaptureFailureAsync( - () => EndCapturedProcessAsync(_handshake.CapturedChildProcess, cleanup.Token), failures); + () => _handshake.WaitForChildExitAsync(cleanup.Token)); await CaptureFailureAsync( - () => EndCapturedProcessAsync(_handshake.CapturedRootProcess, cleanup.Token), failures); + () => _handshake.WaitForRootExitAsync(cleanup.Token)); } finally { @@ -98,23 +129,23 @@ await CaptureFailureAsync( } catch (Exception exception) { - failures.Add(exception); + _cleanupFailures.Add(exception); } try { - _startupCancellation.Dispose(); + _startupCancellation?.Dispose(); } catch (Exception exception) { - failures.Add(exception); + _cleanupFailures.Add(exception); } } - ThrowCleanupFailures(failures); + ThrowCleanupFailures(_cleanupFailures); } - private static void CaptureFailure(Action cleanup, List failures) + private void CaptureFailure(Action cleanup) { try { @@ -122,11 +153,11 @@ private static void CaptureFailure(Action cleanup, List failures) } catch (Exception exception) { - failures.Add(exception); + _cleanupFailures.Add(exception); } } - private static async Task CaptureFailureAsync(Func cleanup, List failures) + private async Task CaptureFailureAsync(Func cleanup) { try { @@ -134,17 +165,23 @@ private static async Task CaptureFailureAsync(Func cleanup, List failures) + 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(); @@ -159,10 +196,15 @@ 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) + catch (OperationCanceledException exception) when (_startup?.IsCanceled is true) { _ = exception; } @@ -172,14 +214,12 @@ private async Task AwaitStartupCleanupAsync(CancellationToken cancellationToken) } } - private static async Task EndCapturedProcessAsync(Process? process, CancellationToken cancellationToken) + private async Task AwaitObservationCleanupAsync(CancellationToken cancellationToken) { - if (process is null || process.HasExited) + var observation = _observation; + if (observation is not null) { - return; + await observation.WaitAsync(cancellationToken); } - - process.Kill(); - await process.WaitForExitAsync(cancellationToken); } }