diff --git a/tests/OpenCode.Sdk.Tests/Hosting/Launcher/OpenCodeServerSpawnRequestTests.cs b/tests/OpenCode.Sdk.Tests/Hosting/Launcher/OpenCodeServerSpawnRequestTests.cs new file mode 100644 index 00000000..15d454a2 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Hosting/Launcher/OpenCodeServerSpawnRequestTests.cs @@ -0,0 +1,191 @@ +using System.ComponentModel; +using NSubstitute; +using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Tests.Support; +using TUnit.Assertions.Enums; + +namespace OpenCode.Sdk.Tests.Hosting.Launcher; + +/// The standalone door's pre-spawn refusals and the request it hands to the host's strategy. +public sealed class OpenCodeServerSpawnRequestTests +{ + private static string Command => OperatingSystem.IsWindows() ? @"C:\oc\opencode.exe" : "/opt/oc/opencode"; + + /// A rooted executable and a leading argument are both checked before spawn, without pinning #401's parameter name. + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task StartAsync_Should_Refuse_A_Nul_In_A_Command_Entry_Before_Spawn(bool executable) + { + var spawn = new RecordingSpawn(); + var options = new OpenCodeServerOptions { Command = executable ? [Command + "\0", "serve"] : [Command, "serve\0"] }; + + var exception = await Assert.That(async () => await OpenCodeServer.StartWithSeamsAsync(options, Seams(spawn), CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).IsEqualTo(new ArgumentException( + "OpenCodeServerOptions.Command entries cannot contain NUL.", exception.ParamName).Message); + await Assert.That(spawn.Calls).IsEqualTo(0); + } + + /// Neither an environment name nor its value may be silently cut at a NUL by native marshalling. + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task StartAsync_Should_Refuse_A_Nul_In_An_Environment_Entry_Before_Spawn(bool name) + { + var spawn = new RecordingSpawn(); + var options = new OpenCodeServerOptions + { + Command = [Command], + Environment = new Dictionary(StringComparer.Ordinal) { [name ? "ENTRY\0" : "ENTRY"] = name ? "value" : "value\0" }, + }; + var seams = Seams(spawn); + + var exception = await Assert.That(async () => await OpenCodeServer.StartWithSeamsAsync(options, seams, cancellationToken: CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).IsEqualTo(new ArgumentException( + "OpenCodeServerOptions.Environment names and values cannot contain NUL.", exception.ParamName).Message); + await Assert.That(spawn.Calls).IsEqualTo(0); + } + + /// The launcher tail follows every supplied argument; Windows starts that exact command suspended. + [Test] + public async Task StartAsync_Should_Append_The_Standalone_Arguments_After_The_Supplied_Arguments() + { + var spawn = new RecordingSpawn(); + var options = new OpenCodeServerOptions { Command = [Command, "serve", "two words", "quoted\"argument"] }; + + _ = await Assert.That(async () => await OpenCodeServer.StartWithSeamsAsync(options, Seams(spawn), CancellationToken.None)) + .Throws(); + + string[] argv = [Command, "serve", "two words", "quoted\"argument", "--stdio", "--port", "0"]; + if (OperatingSystem.IsWindows()) + { + await Assert.That(spawn.WindowsRequest!.ApplicationPath).IsEqualTo(Command); + await Assert.That(spawn.WindowsRequest.CommandLine).IsEqualTo( + """C:\oc\opencode.exe serve "two words" "quoted\"argument" --stdio --port 0"""); + await Assert.That(spawn.WindowsRequest.StartSuspended).IsTrue(); + } + else + { + await Assert.That(new[] { spawn.PosixRequest!.ExecutablePath }.Concat(spawn.PosixRequest.Arguments)) + .IsEquivalentTo(argv, CollectionOrdering.Matching); + } + } + + /// Each start injects a fresh base64url lease credential, never the caller's replacement. + [Test] + public async Task StartAsync_Should_Inject_A_Fresh_Base64Url_Password_Over_The_Callers_Entry() + { + var spawn = new RecordingSpawn(); + var seams = Seams(spawn); + const string callerPassword = "caller-password"; + var options = new OpenCodeServerOptions + { + Command = [Command], + Environment = new Dictionary(StringComparer.Ordinal) { ["OPENCODE_PASSWORD"] = callerPassword }, + }; + + _ = await Assert.That(async () => await OpenCodeServer.StartWithSeamsAsync(options, seams, CancellationToken.None)) + .Throws(); + var first = spawn.Environment["OPENCODE_PASSWORD"]; + _ = await Assert.That(async () => await OpenCodeServer.StartWithSeamsAsync(options, seams, CancellationToken.None)) + .Throws(); + var second = spawn.Environment["OPENCODE_PASSWORD"]; + + await Assert.That(first).Matches("^[A-Za-z0-9_-]{43}$"); + await Assert.That(second).Matches("^[A-Za-z0-9_-]{43}$"); + await Assert.That(second).IsNotEqualTo(first); + await Assert.That(first).IsNotEqualTo(callerPassword); + await Assert.That(second).IsNotEqualTo(callerPassword); + } + + /// Only the host's literal 1 inherits stderr; the ownership lease and readiness stream remain pipes. + [Test] + [Arguments("1", true)] + [Arguments("true", false)] + public async Task StartAsync_Should_Inherit_Stderr_Only_For_The_Hosts_Print_Logs_Switch(string printLogs, bool inherit) + { + var spawn = new RecordingSpawn(); + var host = new Dictionary(StringComparer.Ordinal) { ["OPENCODE_PRINT_LOGS"] = printLogs }; + var options = new OpenCodeServerOptions { Command = [Command] }; + var expected = inherit ? ChildStreamRoute.Inherit : ChildStreamRoute.Pipe; + + _ = await Assert.That(async () => await OpenCodeServer.StartWithSeamsAsync(options, Seams(spawn, host), CancellationToken.None)) + .Throws(); + + if (OperatingSystem.IsWindows()) + { + await Assert.That(spawn.WindowsRequest!.StandardStreams!.Input).IsEqualTo(ChildStreamRoute.Pipe); + await Assert.That(spawn.WindowsRequest.StandardStreams.Output).IsEqualTo(ChildStreamRoute.Pipe); + await Assert.That(spawn.WindowsRequest.StandardStreams.Error).IsEqualTo(expected); + } + else + { + await Assert.That(spawn.PosixRequest!.StandardInput).IsEqualTo(ChildStreamRoute.Pipe); + await Assert.That(spawn.PosixRequest.StandardOutput).IsEqualTo(ChildStreamRoute.Pipe); + await Assert.That(spawn.PosixRequest.StandardError).IsEqualTo(expected); + } + } + + /// A spawn failure names a meaningful directory, while a blank directory is passed as null and omitted. + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task StartAsync_Should_Name_The_Working_Directory_Only_When_It_Is_Not_Blank(bool directory) + { + var spawn = new RecordingSpawn(); + var work = OperatingSystem.IsWindows() ? @"C:\work" : "/work"; + var options = new OpenCodeServerOptions { Command = [Command], WorkingDirectory = directory ? work : " " }; + + var exception = await Assert.That(async () => await OpenCodeServer.StartWithSeamsAsync(options, Seams(spawn), CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).IsEqualTo(directory + ? $"Failed to start the server command '{Command}' in the working directory '{work}'." + : $"Failed to start the server command '{Command}'."); + await Assert.That(OperatingSystem.IsWindows() ? spawn.WindowsRequest!.WorkingDirectory : spawn.PosixRequest!.WorkingDirectory) + .IsEqualTo(directory ? work : null); + } + + private static LauncherSeams Seams(RecordingSpawn spawn, IReadOnlyDictionary? host = null) => new() + { + WindowsSpawn = spawn, + Spawn = spawn, + Job = Substitute.For(), + WindowsExits = Substitute.For(), + TreeKill = Substitute.For(), + Signals = Substitute.For(), + Exits = Substitute.For(), + Time = new ManualTimeProvider(), + HostEnvironment = host ?? new Dictionary(StringComparer.Ordinal), + }; + + /// Records the host strategy's request and refuses to create a process. + private sealed class RecordingSpawn : IPosixSpawn, IWindowsSpawn + { + public int Calls { get; private set; } + + public PosixSpawnRequest? PosixRequest { get; private set; } + + public WindowsSpawnRequest? WindowsRequest { get; private set; } + + public IReadOnlyDictionary Environment => WindowsRequest?.Environment ?? PosixRequest!.Environment; + + public PosixSpawnedChild Spawn(PosixSpawnRequest request) + { + Calls++; + PosixRequest = request; + throw new Win32Exception(2); + } + + public WindowsSpawnedChild Spawn(WindowsSpawnRequest request) + { + Calls++; + WindowsRequest = request; + throw new Win32Exception(2); + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Hosting/Launcher/WindowsServerChildTests.cs b/tests/OpenCode.Sdk.Tests/Hosting/Launcher/WindowsServerChildTests.cs new file mode 100644 index 00000000..3b82d817 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Hosting/Launcher/WindowsServerChildTests.cs @@ -0,0 +1,145 @@ +using System.ComponentModel; +using System.Diagnostics; +using Microsoft.Win32.SafeHandles; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Tests.Harness; +using OpenCode.Sdk.Tests.Support; +using Testably.Abstractions; + +namespace OpenCode.Sdk.Tests.Hosting.Launcher; + +/// The Windows strategy's spawn and adoption failures, including the running child it must end. +[NotInParallel(ParallelConstraintKeys.ServerProcess)] +public sealed class WindowsServerChildTests +{ + private static readonly RealFileSystem FileSystem = new(); + private static readonly TimeSpan Bound = TimeSpan.FromSeconds(30); + + /// A native spawn failure preserves its cause and names the resolution and directory outside the command quotes. + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task SpawnAsync_Should_Name_The_Command_And_Preserve_The_Spawn_Failure(bool details) + { + var seams = Seams(); + var failure = new Win32Exception(2); + seams.WindowsSpawn.Spawn(Arg.Any()).Throws(failure); + var start = Start(new ResolvedExecutable("opencode", details ? @"C:\tools\opencode.exe" : "opencode", IsBatchScript: false)) with + { WorkingDirectory = details ? @"C:\work" : null }; + + var exception = await Assert.That(async () => await WindowsServerChild.SpawnAsync(start, seams)) + .Throws(); + + await Assert.That(exception!.Message).IsEqualTo(details + ? @"Failed to start the server command 'opencode' (resolved to 'C:\tools\opencode.exe') in the working directory 'C:\work'." + : "Failed to start the server command 'opencode'."); + await Assert.That(exception.InnerException).IsSameReferenceAs(failure); + } + + /// A watch creation failure ends the already-running child with code 1 and closes the handle it offered the watch; the session leak guard proves no watch is left. + [Test] + [Timeout(120_000)] + public async Task SpawnAsync_Should_End_The_Child_When_Its_Exit_Cannot_Be_Watched(CancellationToken cancellationToken) + { + if (!OperatingSystem.IsWindows()) + { + BranchReport.Print("POSIX — the Windows watch adoption path requires a Windows process handle"); + return; + } + + await using var exits = new RefusingChildExit(); + var seams = Seams(exits, exits); + var fixture = new ServiceFixtureCommand(FileSystem).Resolve(); + var resolved = new ExecutableResolver(ExecutableSearchEnvironment.ForCurrentProcess()).Resolve(fixture[0]); + var executable = new ResolvedExecutable("fixture", resolved.Path, IsBatchScript: false); + var start = Start(executable) with { SuppliedArguments = [fixture[1], "idle"] }; + + var exception = await Assert.That(async () => await WindowsServerChild.SpawnAsync(start, seams)) + .Throws(); + + await Assert.That(exception!.Message).IsEqualTo( + $"The server command 'fixture' (resolved to '{resolved.Path}') was started, but its exit could not be watched or its output read, so it was ended."); + await Assert.That(exception.InnerException).IsSameReferenceAs(exits.Failure); + await Assert.That(exits.Held).IsNotNull(); + await Assert.That(await ProcessObservation.ObserveExitWithinAsync(exits.Held!, Bound, cancellationToken)).IsTrue(); + await Assert.That(exits.Held!.ExitCode).IsEqualTo(1); + await Assert.That(exits.Watched!.IsClosed).IsTrue(); + } + + private static ServerChildStart Start(ResolvedExecutable executable) => new() + { + Executable = executable, + SuppliedArguments = [], + LauncherArguments = [], + Password = nameof(WindowsServerChildTests), + GracefulShutdownTimeout = TimeSpan.FromSeconds(3), + OnStandardOutput = static _ => { }, + OnStandardError = static _ => { }, + }; + + private static LauncherSeams Seams(IWindowsSpawn? spawn = null, IWindowsChildExit? exits = null) => new() + { + WindowsSpawn = spawn ?? Substitute.For(), + WindowsExits = exits ?? Substitute.For(), + Job = Substitute.For(), + Signals = Substitute.For(), + TreeKill = Substitute.For(), + Exits = Substitute.For(), + Time = new ManualTimeProvider(), + Spawn = Substitute.For(), + HostEnvironment = new Dictionary(StringComparer.OrdinalIgnoreCase), + }; + + /// Takes an independent observation handle before refusing to create any exit watch. + private sealed class RefusingChildExit : IWindowsChildExit, IWindowsSpawn, IAsyncDisposable + { + private readonly WindowsSpawn _spawn = new(); + + public Win32Exception Failure { get; } = new(6); + + public Process? Held { get; private set; } + + public SafeProcessHandle? Watched { get; private set; } + + public WindowsSpawnedChild Spawn(WindowsSpawnRequest request) + { + var child = _spawn.Spawn(request); + try + { + Held = Process.GetProcessById(child.ProcessId); + _ = Held.Handle; + return child; + } + catch + { + // The strategy never saw this child, so nothing else would end it or close its handles. + _ = WindowsInterop.TerminateProcess(child.Process, 1); + child.MainThread?.Dispose(); + child.StandardInput?.Dispose(); + child.StandardOutput?.Dispose(); + child.StandardError?.Dispose(); + child.Process.Dispose(); + throw; + } + } + + public IWindowsExitWatch Watch(SafeProcessHandle process) + { + Watched = process; + throw Failure; + } + + public async ValueTask DisposeAsync() + { + if (Held is { HasExited: false }) + { + Held.Kill(); + await Held.WaitForExitAsync(); + } + + Held?.Dispose(); + } + } +} diff --git a/tests/OpenCode.Sdk.Tests/Hosting/Platform/Posix/PosixSignalNamesTests.cs b/tests/OpenCode.Sdk.Tests/Hosting/Platform/Posix/PosixSignalNamesTests.cs index ff33288e..80c1b3e0 100644 --- a/tests/OpenCode.Sdk.Tests/Hosting/Platform/Posix/PosixSignalNamesTests.cs +++ b/tests/OpenCode.Sdk.Tests/Hosting/Platform/Posix/PosixSignalNamesTests.cs @@ -11,6 +11,20 @@ public sealed class PosixSignalNamesTests { public static IEnumerable StandardSignals() => Enumerable.Range(1, 31); + /// The host selects macOS's numbering only on macOS; Windows exposes the otherwise-unused Linux table. + [Test] + public async Task ForCurrentProcess_Should_Select_The_Hosts_Signal_Numbering() + { + if (OperatingSystem.IsMacOS()) + { + await Assert.That(PosixSignalNames.ForCurrentProcess.NameOf(10)).IsEqualTo("SIGBUS"); + } + else + { + await Assert.That(PosixSignalNames.ForCurrentProcess.NameOf(10)).IsEqualTo("SIGUSR1"); + } + } + [Test] [MethodDataSource(nameof(StandardSignals))] public async Task NameOf_Should_Name_Every_Standard_Signal_On_Both_Systems(int number) diff --git a/tests/OpenCode.Sdk.Tests/Hosting/Platform/Windows/WindowsExitWatchTests.cs b/tests/OpenCode.Sdk.Tests/Hosting/Platform/Windows/WindowsExitWatchTests.cs index a0489db8..e968abbf 100644 --- a/tests/OpenCode.Sdk.Tests/Hosting/Platform/Windows/WindowsExitWatchTests.cs +++ b/tests/OpenCode.Sdk.Tests/Hosting/Platform/Windows/WindowsExitWatchTests.cs @@ -27,6 +27,7 @@ public sealed class WindowsExitWatchTests [Timeout(120_000)] [Arguments(AccessViolation)] [Arguments(7)] + [Arguments(259)] public async Task Exited_Should_Report_The_Code_In_Its_Full_32_Bits(int code, CancellationToken cancellationToken) { if (!OperatingSystem.IsWindows())