Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>The standalone door's pre-spawn refusals and the request it hands to the host's strategy.</summary>
public sealed class OpenCodeServerSpawnRequestTests
{
private static string Command => OperatingSystem.IsWindows() ? @"C:\oc\opencode.exe" : "/opt/oc/opencode";

/// <summary>A rooted executable and a leading argument are both checked before spawn, without pinning #401's parameter name.</summary>
[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<ArgumentException>();

await Assert.That(exception!.Message).IsEqualTo(new ArgumentException(
"OpenCodeServerOptions.Command entries cannot contain NUL.", exception.ParamName).Message);
await Assert.That(spawn.Calls).IsEqualTo(0);
}

/// <summary>Neither an environment name nor its value may be silently cut at a NUL by native marshalling.</summary>
[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<string, string>(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<ArgumentException>();

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);
}

/// <summary>The launcher tail follows every supplied argument; Windows starts that exact command suspended.</summary>
[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<OpenCodeServerException>();

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);
}
}

/// <summary>Each start injects a fresh base64url lease credential, never the caller's replacement.</summary>
[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<string, string>(StringComparer.Ordinal) { ["OPENCODE_PASSWORD"] = callerPassword },
};

_ = await Assert.That(async () => await OpenCodeServer.StartWithSeamsAsync(options, seams, CancellationToken.None))
.Throws<OpenCodeServerException>();
var first = spawn.Environment["OPENCODE_PASSWORD"];
_ = await Assert.That(async () => await OpenCodeServer.StartWithSeamsAsync(options, seams, CancellationToken.None))
.Throws<OpenCodeServerException>();
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);
}

/// <summary>Only the host's literal 1 inherits stderr; the ownership lease and readiness stream remain pipes.</summary>
[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<string, string>(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<OpenCodeServerException>();

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);
}
}

/// <summary>A spawn failure names a meaningful directory, while a blank directory is passed as null and omitted.</summary>
[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<OpenCodeServerException>();

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<string, string>? host = null) => new()
{
WindowsSpawn = spawn,
Spawn = spawn,
Job = Substitute.For<IProcessJob>(),
WindowsExits = Substitute.For<IWindowsChildExit>(),
TreeKill = Substitute.For<IWindowsTreeKill>(),
Signals = Substitute.For<IProcessGroupSignal>(),
Exits = Substitute.For<IChildExitStatus>(),
Time = new ManualTimeProvider(),
HostEnvironment = host ?? new Dictionary<string, string>(StringComparer.Ordinal),
};

/// <summary>Records the host strategy's request and refuses to create a process.</summary>
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<string, string> 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);
}
}
}
145 changes: 145 additions & 0 deletions tests/OpenCode.Sdk.Tests/Hosting/Launcher/WindowsServerChildTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>The Windows strategy's spawn and adoption failures, including the running child it must end.</summary>
[NotInParallel(ParallelConstraintKeys.ServerProcess)]
public sealed class WindowsServerChildTests
{
private static readonly RealFileSystem FileSystem = new();
private static readonly TimeSpan Bound = TimeSpan.FromSeconds(30);

/// <summary>A native spawn failure preserves its cause and names the resolution and directory outside the command quotes.</summary>
[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<WindowsSpawnRequest>()).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<OpenCodeServerException>();

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);
}

/// <summary>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.</summary>
[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<OpenCodeServerException>();

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<IWindowsSpawn>(),
WindowsExits = exits ?? Substitute.For<IWindowsChildExit>(),
Job = Substitute.For<IProcessJob>(),
Signals = Substitute.For<IProcessGroupSignal>(),
TreeKill = Substitute.For<IWindowsTreeKill>(),
Exits = Substitute.For<IChildExitStatus>(),
Time = new ManualTimeProvider(),
Spawn = Substitute.For<IPosixSpawn>(),
HostEnvironment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
};

/// <summary>Takes an independent observation handle before refusing to create any exit watch.</summary>
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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ public sealed class PosixSignalNamesTests
{
public static IEnumerable<int> StandardSignals() => Enumerable.Range(1, 31);

/// <summary>The host selects macOS's numbering only on macOS; Windows exposes the otherwise-unused Linux table.</summary>
[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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading