Skip to content

Commit 4d64809

Browse files
authored
Add Go interop testing runner (#43)
* Add Go interop testing runner * Annotate test Go code snippets with language directive and update Go modules syntax * Refactor Go module handling and cross-targeting compatibility Enhanced Go module initialization logic, updated dependency example versions, and improved compatibility with pre-.NET Standard 2.0 targets by adding conditional directives.
1 parent 4ab33d6 commit 4d64809

File tree

11 files changed

+743
-0
lines changed

11 files changed

+743
-0
lines changed

orbit.net.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
<Project Path="src/Synadia.Orbit.NatsCli.Plugin/Synadia.Orbit.NatsCli.Plugin.csproj" />
2525
<Project Path="src/Synadia.Orbit.Testing.NatsServerProcessManager/Synadia.Orbit.Testing.NatsServerProcessManager.csproj" />
2626
<Project Path="src/Synadia.Orbit.Counters/Synadia.Orbit.Counters.csproj" />
27+
<Project Path="src/Synadia.Orbit.Testing.GoHarness/Synadia.Orbit.Testing.GoHarness.csproj" />
2728
</Folder>
2829
<Folder Name="/tests/">
2930
<File Path="tests/Directory.Build.props" />
@@ -39,6 +40,7 @@
3940
<Project Path="tests/Synadia.Orbit.Counters.Test/Synadia.Orbit.Counters.Test.csproj" />
4041
<Project Path="tests/Synadia.Orbit.Testing.NatsServerProcessManager.Test/Synadia.Orbit.Testing.NatsServerProcessManager.Test.csproj" />
4142
<Project Path="tests/Synadia.Orbit.Benchmark/Synadia.Orbit.Benchmark.csproj" />
43+
<Project Path="tests/Synadia.Orbit.Testing.GoHarness.Test/Synadia.Orbit.Testing.GoHarness.Test.csproj" />
4244
</Folder>
4345
<Folder Name="/tools/">
4446
<Project Path="tools/DocsExamples/DocsExamples.csproj" />
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) Synadia Communications, Inc. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0.
3+
4+
namespace Synadia.Orbit.Testing.GoHarness;
5+
6+
/// <summary>
7+
/// Thrown when Go code fails to compile.
8+
/// </summary>
9+
public class GoCompilationException : Exception
10+
{
11+
/// <summary>
12+
/// Initializes a new instance of the <see cref="GoCompilationException"/> class.
13+
/// </summary>
14+
/// <param name="message">The error message including compiler output.</param>
15+
public GoCompilationException(string message)
16+
: base(message)
17+
{
18+
}
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) Synadia Communications, Inc. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0.
3+
4+
namespace Synadia.Orbit.Testing.GoHarness;
5+
6+
/// <summary>
7+
/// Thrown when the Go toolchain is not found on PATH.
8+
/// </summary>
9+
public class GoNotFoundException : Exception
10+
{
11+
/// <summary>
12+
/// Initializes a new instance of the <see cref="GoNotFoundException"/> class.
13+
/// </summary>
14+
/// <param name="message">The error message.</param>
15+
public GoNotFoundException(string message)
16+
: base(message)
17+
{
18+
}
19+
}
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
// Copyright (c) Synadia Communications, Inc. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0.
3+
4+
#pragma warning disable VSTHRD103
5+
#pragma warning disable VSTHRD105
6+
7+
using System.Diagnostics;
8+
using System.Runtime.InteropServices;
9+
10+
namespace Synadia.Orbit.Testing.GoHarness;
11+
12+
/// <summary>
13+
/// Manages a Go process compiled from inline source code, providing stdin/stdout
14+
/// communication for cross-language testing scenarios.
15+
/// </summary>
16+
public class GoProcess : IAsyncDisposable, IDisposable
17+
{
18+
private readonly Process _process;
19+
private readonly string _tempDir;
20+
private readonly Action<string> _logger;
21+
private bool _disposed;
22+
23+
private GoProcess(Process process, string tempDir, Action<string> logger)
24+
{
25+
_process = process;
26+
_tempDir = tempDir;
27+
_logger = logger;
28+
}
29+
30+
/// <summary>
31+
/// Gets a value indicating whether the Go process has exited.
32+
/// </summary>
33+
public bool HasExited => _process.HasExited;
34+
35+
/// <summary>
36+
/// Gets the exit code of the Go process. Only valid after the process has exited.
37+
/// </summary>
38+
public int ExitCode => _process.ExitCode;
39+
40+
/// <summary>
41+
/// Gets the process ID of the running Go process.
42+
/// </summary>
43+
public int Pid => _process.Id;
44+
45+
/// <summary>
46+
/// Compiles and runs inline Go source code, returning a <see cref="GoProcess"/>
47+
/// that can communicate with the running program via stdin/stdout.
48+
/// </summary>
49+
/// <param name="goCode">The Go source code to compile and run.</param>
50+
/// <param name="logger">Optional logging callback.</param>
51+
/// <param name="goModules">Optional list of Go module dependencies (e.g. <c>"github.com/nats-io/[email protected]"</c>).</param>
52+
/// <param name="cancellationToken">Cancellation token.</param>
53+
/// <returns>A <see cref="GoProcess"/> managing the running program.</returns>
54+
/// <exception cref="GoNotFoundException">Thrown when <c>go</c> is not found on PATH.</exception>
55+
/// <exception cref="GoCompilationException">Thrown when the Go code fails to compile.</exception>
56+
public static async Task<GoProcess> RunCodeAsync(
57+
string goCode,
58+
Action<string>? logger = null,
59+
string[]? goModules = null,
60+
CancellationToken cancellationToken = default)
61+
{
62+
GoToolchain.EnsureAvailable();
63+
64+
var log = logger ?? (_ => { });
65+
var tempDir = Path.Combine(Path.GetTempPath(), "orbit-go-harness", Guid.NewGuid().ToString());
66+
Directory.CreateDirectory(tempDir);
67+
68+
log($"TempDir: {tempDir}");
69+
70+
try
71+
{
72+
// Write Go source file
73+
var mainGoPath = Path.Combine(tempDir, "main.go");
74+
File.WriteAllText(mainGoPath, goCode);
75+
76+
// Initialize Go module with a valid module path (requires dot in first element)
77+
await RunGoCommandAsync(["mod", "init", "example.com/testharness"], tempDir, log, cancellationToken);
78+
79+
// Add module dependencies if specified
80+
if (goModules != null)
81+
{
82+
foreach (var module in goModules)
83+
{
84+
await RunGoCommandAsync(["get", module], tempDir, log, cancellationToken);
85+
}
86+
}
87+
88+
// Run go mod tidy
89+
await RunGoCommandAsync(["mod", "tidy"], tempDir, log, cancellationToken);
90+
91+
// Build the binary
92+
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
93+
var binaryName = isWindows ? "testharness.exe" : "testharness";
94+
var binaryPath = Path.Combine(tempDir, binaryName);
95+
96+
await RunGoCommandAsync(["build", "-o", binaryPath, "."], tempDir, log, cancellationToken);
97+
98+
log($"Binary: {binaryPath}");
99+
100+
// Start the compiled binary
101+
var psi = new ProcessStartInfo
102+
{
103+
FileName = binaryPath,
104+
UseShellExecute = false,
105+
RedirectStandardInput = true,
106+
RedirectStandardOutput = true,
107+
RedirectStandardError = true,
108+
CreateNoWindow = true,
109+
WorkingDirectory = tempDir,
110+
};
111+
112+
var process = new Process { StartInfo = psi };
113+
process.Start();
114+
115+
log($"Started Go process PID={process.Id}");
116+
117+
return new GoProcess(process, tempDir, log);
118+
}
119+
catch
120+
{
121+
// Clean up temp dir on failure
122+
try
123+
{
124+
Directory.Delete(tempDir, recursive: true);
125+
}
126+
catch
127+
{
128+
// best effort
129+
}
130+
131+
throw;
132+
}
133+
}
134+
135+
/// <summary>
136+
/// Writes a line to the Go process's standard input.
137+
/// </summary>
138+
/// <param name="line">The line to write.</param>
139+
/// <param name="cancellationToken">Cancellation token.</param>
140+
/// <returns>A task representing the asynchronous operation.</returns>
141+
public async Task WriteLineAsync(string line, CancellationToken cancellationToken = default)
142+
{
143+
cancellationToken.ThrowIfCancellationRequested();
144+
_logger($"-> {line}");
145+
await _process.StandardInput.WriteLineAsync(line);
146+
#if NETSTANDARD2_0 || NETSTANDARD2_1
147+
await _process.StandardInput.FlushAsync();
148+
#else
149+
await _process.StandardInput.FlushAsync(cancellationToken);
150+
#endif
151+
}
152+
153+
/// <summary>
154+
/// Reads a line from the Go process's standard output.
155+
/// </summary>
156+
/// <param name="cancellationToken">Cancellation token.</param>
157+
/// <returns>The line read, or <c>null</c> if the stream has ended.</returns>
158+
public async Task<string?> ReadLineAsync(CancellationToken cancellationToken = default)
159+
{
160+
cancellationToken.ThrowIfCancellationRequested();
161+
#if NETSTANDARD2_0 || NETSTANDARD2_1
162+
var line = await _process.StandardOutput.ReadLineAsync();
163+
#else
164+
var line = await _process.StandardOutput.ReadLineAsync(cancellationToken);
165+
#endif
166+
_logger($"<- {line}");
167+
return line;
168+
}
169+
170+
/// <summary>
171+
/// Reads all remaining standard error output from the Go process.
172+
/// </summary>
173+
/// <param name="cancellationToken">Cancellation token.</param>
174+
/// <returns>The stderr content.</returns>
175+
public async Task<string> ReadStdErrAsync(CancellationToken cancellationToken = default)
176+
{
177+
cancellationToken.ThrowIfCancellationRequested();
178+
#if NETSTANDARD2_0 || NETSTANDARD2_1
179+
return await _process.StandardError.ReadToEndAsync();
180+
#else
181+
return await _process.StandardError.ReadToEndAsync(cancellationToken);
182+
#endif
183+
}
184+
185+
/// <summary>
186+
/// Closes the standard input stream, signaling EOF to the Go process.
187+
/// </summary>
188+
public void CloseInput()
189+
{
190+
_process.StandardInput.Close();
191+
}
192+
193+
/// <summary>
194+
/// Waits for the Go process to exit.
195+
/// </summary>
196+
/// <param name="cancellationToken">Cancellation token.</param>
197+
/// <returns>A task representing the asynchronous operation.</returns>
198+
public async Task WaitForExitAsync(CancellationToken cancellationToken = default)
199+
{
200+
#if NETSTANDARD2_0 || NETSTANDARD2_1
201+
await Task.Run(() => _process.WaitForExit(), cancellationToken);
202+
#else
203+
await _process.WaitForExitAsync(cancellationToken);
204+
#endif
205+
}
206+
207+
/// <inheritdoc />
208+
public ValueTask DisposeAsync()
209+
{
210+
Dispose();
211+
return default;
212+
}
213+
214+
/// <inheritdoc />
215+
public void Dispose()
216+
{
217+
if (_disposed)
218+
{
219+
return;
220+
}
221+
222+
_disposed = true;
223+
224+
if (!_process.HasExited)
225+
{
226+
try
227+
{
228+
_process.Kill();
229+
_process.WaitForExit(5_000);
230+
}
231+
catch
232+
{
233+
// best effort
234+
}
235+
}
236+
237+
_process.Dispose();
238+
239+
for (var i = 0; i < 3; i++)
240+
{
241+
try
242+
{
243+
Directory.Delete(_tempDir, recursive: true);
244+
break;
245+
}
246+
catch
247+
{
248+
Thread.Sleep(100);
249+
}
250+
}
251+
}
252+
253+
private static async Task RunGoCommandAsync(
254+
string[] args,
255+
string workingDirectory,
256+
Action<string> log,
257+
CancellationToken cancellationToken)
258+
{
259+
cancellationToken.ThrowIfCancellationRequested();
260+
261+
var goExe = GoToolchain.FindGo()!;
262+
var psi = new ProcessStartInfo
263+
{
264+
FileName = goExe,
265+
UseShellExecute = false,
266+
RedirectStandardOutput = true,
267+
RedirectStandardError = true,
268+
CreateNoWindow = true,
269+
WorkingDirectory = workingDirectory,
270+
};
271+
272+
#if NETSTANDARD2_0
273+
psi.Arguments = string.Join(" ", Array.ConvertAll(args, QuoteArgument));
274+
#else
275+
foreach (var arg in args)
276+
{
277+
psi.ArgumentList.Add(arg);
278+
}
279+
#endif
280+
281+
var argsDisplay = string.Join(" ", args);
282+
log($"Running: go {argsDisplay}");
283+
284+
using var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start go process");
285+
286+
// Read stdout and stderr concurrently to avoid deadlocks
287+
// when the process writes enough to fill an OS pipe buffer
288+
#if NETSTANDARD2_0 || NETSTANDARD2_1
289+
var stdoutTask = process.StandardOutput.ReadToEndAsync();
290+
var stderrTask = process.StandardError.ReadToEndAsync();
291+
#else
292+
var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
293+
var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
294+
#endif
295+
296+
var stdout = await stdoutTask;
297+
var stderr = await stderrTask;
298+
299+
#if NETSTANDARD2_0 || NETSTANDARD2_1
300+
process.WaitForExit();
301+
#else
302+
await process.WaitForExitAsync(cancellationToken);
303+
#endif
304+
305+
if (!string.IsNullOrWhiteSpace(stdout))
306+
{
307+
log($"stdout: {stdout}");
308+
}
309+
310+
if (!string.IsNullOrWhiteSpace(stderr))
311+
{
312+
log($"stderr: {stderr}");
313+
}
314+
315+
if (process.ExitCode != 0)
316+
{
317+
throw new GoCompilationException($"'go {argsDisplay}' failed with exit code {process.ExitCode}:\n{stderr}");
318+
}
319+
}
320+
321+
#if NETSTANDARD2_0
322+
private static string QuoteArgument(string arg)
323+
{
324+
if (arg.Length > 0 && arg.IndexOfAny(new[] { ' ', '"', '\\' }) < 0)
325+
{
326+
return arg;
327+
}
328+
329+
return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
330+
}
331+
#endif
332+
}

0 commit comments

Comments
 (0)