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
+ }
}