From 49fb9647c88f45a715daaaf5b45d8e665a2ef065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 17 Oct 2026 02:17:32 +0300 Subject: [PATCH] test: characterize the background-service contender ahead of RT-E6 Pins today's observable contender behaviour that RT-E6 step 4 (StderrTail, ChildExitWatch) and step 2 (ChildCommand, HostEnvironment) rewrite: no redaction without a ticket, the read-fault failure until disposal, an unknown exit at stderr end on POSIX, Release dropping the tail, and the spawner's NUL and batch refusals, spawn-failure text, request streams, overlay environment, handoff variable and missing stderr read end. --- .../ServiceContenderSpawnerRequestTests.cs | 192 ++++++++++++++++++ .../Contender/ServiceContenderTests.cs | 76 ++++++- 2 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Hosting/BackgroundService/Contender/ServiceContenderSpawnerRequestTests.cs diff --git a/tests/OpenCode.Sdk.Tests/Hosting/BackgroundService/Contender/ServiceContenderSpawnerRequestTests.cs b/tests/OpenCode.Sdk.Tests/Hosting/BackgroundService/Contender/ServiceContenderSpawnerRequestTests.cs new file mode 100644 index 00000000..1c071a3c --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Hosting/BackgroundService/Contender/ServiceContenderSpawnerRequestTests.cs @@ -0,0 +1,192 @@ +using System.ComponentModel; +using Microsoft.Win32.SafeHandles; +using NSubstitute; +using NSubstitute.Extensions; +using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Tests.Support; + +namespace OpenCode.Sdk.Tests.Hosting.BackgroundService.Contender; + +/// +/// The contender spawner's pre-spawn refusals, failure text and platform request: no process is +/// started. Each platform records its own request through substitute spawn seams; environment +/// assertions read only that request, not how many snapshots or merges produced it. +/// +public sealed class ServiceContenderSpawnerRequestTests +{ + private const string CommandPath = "/tools/opencode"; + private const string HandoffVariable = "OPENCODE_PTY_HANDOFF"; + + [Test] + [Arguments("bad\0key", "value")] + [Arguments("key", "bad\0value")] + public async Task Spawn_Should_Refuse_Nul_In_An_Overlay_Key_Or_Value(string key, string value) + { + var recording = new RecordingSpawn(); + var start = Start(environment: new Dictionary(StringComparer.Ordinal) { [key] = value }); + + var exception = await Assert.That(() => Spawner(recording).Spawn(start)).Throws(); + + await Assert.That(exception!.Message).IsEqualTo(new ArgumentException( + "ContenderStartInfo.Environment keys and values cannot contain NUL.", exception.ParamName).Message); + _ = recording.Posix.DidNotReceive().Spawn(Arg.Any()); + _ = recording.Windows.DidNotReceive().Spawn(Arg.Any()); + } + + [Test] + public async Task Spawn_Should_Route_A_Batch_Shim_Through_Cmd_Only_On_Windows() + { + var recording = new RecordingSpawn(); + var start = Start(executable: new ResolvedExecutable("/tools/opencode.cmd", "/tools/opencode.cmd", IsBatchScript: true)); + + var exception = await Assert.That(() => Spawner(recording).Spawn(start)).Throws(); + + if (OperatingSystem.IsWindows()) + { + var request = recording.WindowsRequest!; + await Assert.That(request.ApplicationPath).IsEqualTo(BatchCommandLine.InterpreterPath); + await Assert.That(request.Detached).IsFalse(); + BranchReport.Print("Windows — batch shim uses cmd.exe without detaching"); + } + else + { + await Assert.That(exception!.Message).IsEqualTo( + "The background service contender routes through cmd.exe, which exists only on Windows: no contender was started."); + _ = recording.Posix.DidNotReceive().Spawn(Arg.Any()); + _ = recording.Windows.DidNotReceive().Spawn(Arg.Any()); + BranchReport.Print("POSIX — batch shim refused before spawning"); + } + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Spawn_Should_Name_The_Command_With_Any_Resolution_Inside_The_Quotes(bool resolved) + { + var recording = new RecordingSpawn(); + var start = Start(executable: new ResolvedExecutable(resolved ? "oc" : CommandPath, CommandPath, IsBatchScript: false)); + + var exception = await Assert.That(() => Spawner(recording).Spawn(start)).Throws(); + + await Assert.That(exception!.Message).IsEqualTo(resolved + ? "Failed to start the background service contender 'oc (resolved to '/tools/opencode')'." + : "Failed to start the background service contender '/tools/opencode'."); + await Assert.That(exception.InnerException).IsSameReferenceAs(recording.Failure); + } + + [Test] + public async Task Spawn_Should_Request_Null_Input_And_Output_Piped_Error_And_An_Overlaid_Environment() + { + const string setVariable = "SDK_CONTENDER_REQUEST_VALUE"; + const string setValue = "overlay value"; + await Assert.That(Environment.GetEnvironmentVariable("PATH")).IsNotNull(); + var recording = new RecordingSpawn(); + var start = Start(environment: new Dictionary(StringComparer.Ordinal) + { + ["PATH"] = null, + [setVariable] = setValue, + }); + + await Assert.That(() => Spawner(recording).Spawn(start)).Throws(); + + await Assert.That(recording.Environment).DoesNotContainKey("PATH"); + await Assert.That(recording.Environment[setVariable]).IsEqualTo(setValue); + + if (OperatingSystem.IsWindows()) + { + var request = recording.WindowsRequest!; + await Assert.That(request.StandardStreams!.Input).IsEqualTo(ChildStreamRoute.Null); + await Assert.That(request.StandardStreams.Output).IsEqualTo(ChildStreamRoute.Null); + await Assert.That(request.StandardStreams.Error).IsEqualTo(ChildStreamRoute.Pipe); + await Assert.That(request.Detached).IsTrue(); + BranchReport.Print("Windows — non-batch contender detached with NUL/NUL/pipe"); + } + else + { + var request = recording.PosixRequest!; + await Assert.That(request.StandardInput).IsEqualTo(ChildStreamRoute.Null); + await Assert.That(request.StandardOutput).IsEqualTo(ChildStreamRoute.Null); + await Assert.That(request.StandardError).IsEqualTo(ChildStreamRoute.Pipe); + BranchReport.Print("POSIX — contender request uses null/null/pipe"); + } + } + + [Test] + [Arguments("synthetic-ticket")] + [Arguments("")] + public async Task Spawn_Should_Pass_A_Nonempty_Handoff_And_Remove_An_Empty_One(string ticket) + { + ArgumentNullException.ThrowIfNull(ticket); + var recording = new RecordingSpawn(); + var start = Start(environment: new Dictionary(StringComparer.Ordinal) { [HandoffVariable] = ticket }); + + await Assert.That(() => Spawner(recording).Spawn(start)).Throws(); + + if (ticket.Length == 0) + { + await Assert.That(recording.Environment).DoesNotContainKey(HandoffVariable); + } + else + { + await Assert.That(recording.Environment[HandoffVariable]).IsEqualTo(ticket); + } + } + + [Test] + public async Task Spawn_Should_Refuse_A_Missing_Stderr_Read_End_And_Close_The_Windows_Handle() + { + using var process = new SafeProcessHandle(IntPtr.Zero, ownsHandle: false); + var recording = new RecordingSpawn(); + recording.Posix.Configure().Spawn(Arg.Any()).Returns(new PosixSpawnedChild { ProcessId = Environment.ProcessId }); + recording.Windows.Configure().Spawn(Arg.Any()).Returns(new WindowsSpawnedChild + { + ProcessId = Environment.ProcessId, + Process = process, + }); + + await Assert.That(() => Spawner(recording).Spawn(Start())).Throws(); + + if (OperatingSystem.IsWindows()) + { + await Assert.That(process.IsClosed).IsTrue(); + BranchReport.Print("Windows — missing stderr read end refused and process handle closed"); + } + else + { + BranchReport.Print("POSIX — missing stderr read end refused; no process handle is returned"); + } + } + + private static ServiceContenderSpawner Spawner(RecordingSpawn recording) => new(recording.Posix, recording.Windows); + + private static IServiceContenderSpawner.ContenderStartInfo Start( + ResolvedExecutable? executable = null, IReadOnlyDictionary? environment = null) => + new(executable ?? new ResolvedExecutable(CommandPath, CommandPath, IsBatchScript: false), [], + environment ?? new Dictionary(StringComparer.Ordinal)); + + /// Records the current platform's request and throws one shared native spawn failure. + private sealed class RecordingSpawn + { + public IPosixSpawn Posix { get; } = Substitute.For(); + public IWindowsSpawn Windows { get; } = Substitute.For(); + public Win32Exception Failure { get; } = new(2); + public PosixSpawnRequest? PosixRequest { get; private set; } + public WindowsSpawnRequest? WindowsRequest { get; private set; } + public IReadOnlyDictionary Environment => + OperatingSystem.IsWindows() ? WindowsRequest!.Environment! : PosixRequest!.Environment; + + public RecordingSpawn() + { + Posix.Spawn(Arg.Any()).Returns(call => + { + PosixRequest = call.Arg(); + throw Failure; + }); + Windows.Spawn(Arg.Any()).Returns(call => + { + WindowsRequest = call.Arg(); + throw Failure; + }); + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Hosting/BackgroundService/Contender/ServiceContenderTests.cs b/tests/OpenCode.Sdk.Tests/Hosting/BackgroundService/Contender/ServiceContenderTests.cs index b95af1d3..368a2929 100644 --- a/tests/OpenCode.Sdk.Tests/Hosting/BackgroundService/Contender/ServiceContenderTests.cs +++ b/tests/OpenCode.Sdk.Tests/Hosting/BackgroundService/Contender/ServiceContenderTests.cs @@ -1,5 +1,7 @@ +using System.Globalization; using System.Text; using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Tests.Support; namespace OpenCode.Sdk.Tests.Hosting.BackgroundService.Contender; @@ -76,6 +78,76 @@ public async Task Stderr_Should_Keep_The_Final_8_KiB_Unchanged_Without_A_Ticket( await Assert.That(contender.Stderr).IsEqualTo(kept); } - private static ServiceContender Contender(string stderr, string? redact = Ticket) => - new(Environment.ProcessId, new MemoryStream(Encoding.UTF8.GetBytes(stderr)), process: null, redact); + [Test] + [Arguments(null)] + [Arguments("")] + public async Task Stderr_Should_Keep_Text_Unredacted_When_The_Ticket_Is_Null_Or_Empty(string? redact) + { + const string text = "before ticket after"; + using var contender = Contender(text, redact); + + await Assert.That(contender.Stderr).IsEqualTo(text); + } + + [Test] + public async Task TryGetFailure_Should_Report_A_Read_Fault_Until_Disposal() + { + var failure = new IOException("The stderr read failed."); + using var contender = Contender(stream: new FaultingStderr(failure)); + + var exception = contender.TryGetFailure(); + + await Assert.That(exception).IsTypeOf(); + await Assert.That(exception!.Message).IsEqualTo( + $"The background service contender (pid {Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}) failed to start."); + await Assert.That(exception.InnerException).IsSameReferenceAs(failure); + await Assert.That(contender.Finished).IsTrue(); + + contender.Dispose(); + + await Assert.That(contender.TryGetFailure()).IsNull(); + } + + /// Our own pid cannot be our child: POSIX waitpid reports ECHILD, not an exit code. + [Test] + public async Task Finished_Should_Report_An_Unknown_Exit_At_Stderr_End_When_The_Pid_Is_Not_Our_Child() + { + if (OperatingSystem.IsWindows()) + { + BranchReport.Print("Windows — no faithful fake handle for the POSIX ECHILD observation"); + return; + } + + using var contender = Contender(); + + await Assert.That(contender.Finished).IsTrue(); + await Assert.That(contender.ExitedZero).IsFalse(); + await Assert.That(contender.TryGetFailure()).IsNull(); + BranchReport.Print("POSIX — ECHILD is an unknown exit, finished at EOF and not a failure"); + } + + [Test] + public async Task Release_Should_Drop_The_Retained_Stderr() + { + using var contender = Contender("retained stderr"); + + contender.Release(); + + await Assert.That(contender.Stderr).IsEqualTo(string.Empty); + } + + private static ServiceContender Contender(string stderr = "", string? redact = Ticket, Stream? stream = null) => + new(Environment.ProcessId, stream ?? new MemoryStream(Encoding.UTF8.GetBytes(stderr)), process: null, redact); + + /// Both target-framework read paths throw the same fault before any read suspends. + private sealed class FaultingStderr(IOException failure) : MemoryStream + { + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + throw failure; + +#if NET + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + throw failure; +#endif + } }