From 5c5a01f8f1ba2c26996e53632fc1493078b6dce5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 24 Aug 2026 20:18:56 -0500 Subject: [PATCH] Examples(feat[arena]): Run one shot in arena why: Exercise the arena-owned server without ambient selection while keeping published example code executable. what: - Add descriptor-gated execution and JSON evidence. - Compose compiled snippet regions for the public example. - Cover the arena, README, and synchronizer contracts. --- README.md | 2 +- eng/docs/sync_snippets.py | 26 +- eng/docs/tests/test_sync_snippets.py | 52 +++ examples/LibTmux.Examples/Program.cs | 114 ++++++- examples/LibTmux.Examples/Snippets/OneShot.cs | 12 + .../LibTmux.ExampleTests/ArenaOneShotTests.cs | 298 ++++++++++++++++++ 6 files changed, 491 insertions(+), 13 deletions(-) create mode 100644 eng/docs/tests/test_sync_snippets.py create mode 100644 tests/LibTmux.ExampleTests/ArenaOneShotTests.cs diff --git a/README.md b/README.md index baecfa5..3182a77 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ against every tmux from **3.2a to 3.7b** on **net8.0** and **net10.0**. > settled, and any release may change or remove exported identifiers without a > deprecation period. Pin an exact version. Not recommended for production. - + ```csharp using LibTmux; diff --git a/eng/docs/sync_snippets.py b/eng/docs/sync_snippets.py index 2b8580c..193a0ad 100644 --- a/eng/docs/sync_snippets.py +++ b/eng/docs/sync_snippets.py @@ -9,8 +9,9 @@ READMEs, and nuget.org renders the markdown it is given without resolving anything. -Anchors name the region, and optionally namespaces the document adds above the -block that the snippet file does not need:: +Anchors name one region or a ``+``-joined sequence of regions, and optionally +namespaces the document adds above the block that the snippet file does not +need:: ```csharp @@ -70,14 +71,18 @@ def read_regions() -> dict[str, str]: return regions -def render(name: str, options: str, regions: dict[str, str]) -> str: - """Return the fenced block a document should carry for one region.""" - if name not in regions: - known = ", ".join(sorted(regions)) or "none" - msg = f"no #region named {name}. Published regions: {known}" - raise SystemExit(msg) +def render(name: str, options: str, regions: dict[str, str], used: set[str]) -> str: + """Return the fenced block a document should carry for named regions.""" + bodies: list[str] = [] + for component in name.split("+"): + if component not in regions: + known = ", ".join(sorted(regions)) or "none" + msg = f"no #region named {component}. Published regions: {known}" + raise SystemExit(msg) + used.add(component) + bodies.append(regions[component]) - body = regions[name] + body = "\n".join(bodies) using = USINGS.search(options) if using: namespaces = [part.strip() for part in re.split(r"[ ,]+", using.group("names")) if part.strip()] @@ -91,8 +96,7 @@ def apply(text: str, regions: dict[str, str], used: set[str]) -> str: def replace(match: re.Match[str]) -> str: name = match.group("name") - used.add(name) - block = render(name, match.group("options"), regions) + block = render(name, match.group("options"), regions, used) return f"{match.group('open')}{block}{match.group('close')}" return ANCHOR.sub(replace, text) diff --git a/eng/docs/tests/test_sync_snippets.py b/eng/docs/tests/test_sync_snippets.py new file mode 100644 index 0000000..9f659e1 --- /dev/null +++ b/eng/docs/tests/test_sync_snippets.py @@ -0,0 +1,52 @@ +"""Exercise source-grounded snippet rendering.""" + +from __future__ import annotations + +import pathlib +import runpy +import typing as t + +import pytest + + +def load_synchronizer() -> dict[str, t.Any]: + """Load the synchronizer without making its directory a package.""" + return runpy.run_path(str(pathlib.Path(__file__).parents[1] / "sync_snippets.py")) + + +def test_composed_anchor_concatenates_regions_in_declared_order() -> None: + """A README can publish setup and a shared body without duplicating either.""" + synchronizer = load_synchronizer() + used: set[str] = set() + + rendered = synchronizer["render"]( + "ConnectAndBuild+BuildHierarchy", + "usings: LibTmux", + { + "ConnectAndBuild": "Server server = await Server.ConnectAsync();", + "BuildHierarchy": "await server.CreateSessionAsync(new NewSessionRequest(name: \"build\"));", + }, + used, + ) + + assert rendered == """```csharp +using LibTmux; + +Server server = await Server.ConnectAsync(); +await server.CreateSessionAsync(new NewSessionRequest(name: \"build\")); +``` +""" + assert used == {"ConnectAndBuild", "BuildHierarchy"} + + +def test_composed_anchor_requires_every_region() -> None: + """A misspelled component must fail instead of silently publishing half an example.""" + synchronizer = load_synchronizer() + + with pytest.raises(SystemExit, match="BuildHierarchy"): + synchronizer["render"]( + "ConnectAndBuild+BuildHierarchy", + "", + {"ConnectAndBuild": "Server server = await Server.ConnectAsync();"}, + set(), + ) diff --git a/examples/LibTmux.Examples/Program.cs b/examples/LibTmux.Examples/Program.cs index 28f0f7b..f7eef29 100644 --- a/examples/LibTmux.Examples/Program.cs +++ b/examples/LibTmux.Examples/Program.cs @@ -1,6 +1,8 @@ using System.Diagnostics; +using System.Globalization; using System.Runtime.Versioning; using System.Text; +using System.Text.Json; namespace LibTmux.Examples; @@ -20,9 +22,20 @@ private static async Task Main(string[] args) return 0; } + if (args is ["--arena-one-shot"]) + { + if (OperatingSystem.IsWindows()) + { + Console.Error.WriteLine("The tmux arena requires Linux or macOS."); + return 1; + } + + return await RunArenaOneShotAsync(); + } + if (args.Length != 0) { - Console.Error.WriteLine("usage: LibTmux.Examples [--psmux]"); + Console.Error.WriteLine("usage: LibTmux.Examples [--arena-one-shot|--psmux]"); return 2; } @@ -36,6 +49,84 @@ private static async Task Main(string[] args) return await RunTmuxExamplesAsync(); } + [UnsupportedOSPlatform("windows")] + private static async Task RunArenaOneShotAsync() + { + if (Environment.GetEnvironmentVariable("LIBTMUX_ARENA_DESCRIPTOR") is null or "") + { + ExampleCase example = ExampleCase.Discover().Single( + example => string.Equals( + $"{example.Topic}.{example.Id}", + "OneShot.ConnectAndBuild", + StringComparison.Ordinal)); + await example.RunAsync(); + return 0; + } + + ArenaContract? contract = ArenaContract.Read(); + if (contract is null) + { + return 2; + } + + try + { + Server server = await Server.ConnectAsync( + new ServerConnectionOptions( + tmuxBinaryPath: contract.TmuxBinaryPath, + socketPath: contract.SocketPath)); + await Snippets.OneShot.BuildHierarchy(server); + + IReadOnlyList? identity = await server.DisplayMessageAsync( + new DisplayMessageRequest("#{pid}\t#{socket_path}", returnText: true)); + if (identity is not [string value]) + { + throw new InvalidOperationException("tmux did not report one arena server identity."); + } + + string[] parts = value.Split('\t'); + if (parts.Length != 2 + || !int.TryParse( + parts[0], + NumberStyles.None, + CultureInfo.InvariantCulture, + out int processId) + || processId <= 0 + || !string.Equals(parts[1], contract.SocketPath, StringComparison.Ordinal)) + { + throw new InvalidOperationException("tmux reported an invalid arena server identity."); + } + + IReadOnlyList options = await server.Options.GetAsync( + new GetOptionRequest( + "@libtmux_arena_challenge", + OptionScope.Session, + global: true, + quiet: true)); + if (options is not [{ Value.Raw: string challenge }] || string.IsNullOrWhiteSpace(challenge)) + { + throw new InvalidOperationException("tmux did not report an arena challenge."); + } + + string evidence = JsonSerializer.Serialize( + new + { + schema = 1, + server_pid = processId, + socket_path = parts[1], + challenge, + artifact = contract.Artifact, + }); + Console.WriteLine($"LIBTMUX_ARENA_EVIDENCE={evidence}"); + return 0; + } + catch (Exception failure) when (failure is not OperationCanceledException) + { + Console.Error.WriteLine(failure.Message); + return 1; + } + } + [UnsupportedOSPlatform("windows")] private static async Task RunTmuxExamplesAsync() { @@ -61,4 +152,25 @@ private static async Task RunTmuxExamplesAsync() Console.WriteLine(); return failed == 0 ? 0 : 1; } + + private sealed record ArenaContract(string Artifact, string SocketPath, string TmuxBinaryPath) + { + public static ArenaContract? Read() + { + string? artifact = Environment.GetEnvironmentVariable("LIBTMUX_ARENA_ARTIFACT"); + string? socketPath = Environment.GetEnvironmentVariable("LIBTMUX_SOCKET_PATH"); + string? tmuxBinaryPath = Environment.GetEnvironmentVariable("LIBTMUX_TMUX_BIN"); + if (string.IsNullOrWhiteSpace(artifact) + || string.IsNullOrWhiteSpace(socketPath) + || string.IsNullOrWhiteSpace(tmuxBinaryPath) + || !Path.IsPathFullyQualified(tmuxBinaryPath) + || !string.Equals(artifact, "OneShot.ConnectAndBuild", StringComparison.Ordinal)) + { + Console.Error.WriteLine("The arena contract is incomplete or mismatched."); + return null; + } + + return new ArenaContract(artifact, socketPath, tmuxBinaryPath); + } + } } diff --git a/examples/LibTmux.Examples/Snippets/OneShot.cs b/examples/LibTmux.Examples/Snippets/OneShot.cs index f1b8d74..3356317 100644 --- a/examples/LibTmux.Examples/Snippets/OneShot.cs +++ b/examples/LibTmux.Examples/Snippets/OneShot.cs @@ -12,6 +12,18 @@ public static async Task ConnectAndBuild() { #region ConnectAndBuild Server server = await Server.ConnectAsync(); + #endregion + await BuildHierarchy(server); + } + + /// Builds the hierarchy on a supplied server. + [Example( + "Build a session and window on a supplied server", + RunsInDefaultSuite = false)] + public static async Task BuildHierarchy(Server server) + { + ArgumentNullException.ThrowIfNull(server); + #region BuildHierarchy Session session = await server.CreateSessionAsync(new NewSessionRequest(name: "build")); Window window = await session.CreateWindowAsync(new NewWindowRequest(name: "tests")); Pane pane = (await window.GetPanesAsync())[0]; diff --git a/tests/LibTmux.ExampleTests/ArenaOneShotTests.cs b/tests/LibTmux.ExampleTests/ArenaOneShotTests.cs new file mode 100644 index 0000000..07b1e4b --- /dev/null +++ b/tests/LibTmux.ExampleTests/ArenaOneShotTests.cs @@ -0,0 +1,298 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Text.Json; +using LibTmux.Examples; + +namespace LibTmux.ExampleTests; + +/// Runs the arena entrypoint against a tmux server the example does not own. +[Collection("Examples")] +[UnsupportedOSPlatform("windows")] +public sealed class ArenaOneShotTests +{ + private const string Artifact = "OneShot.ConnectAndBuild"; + private const string Challenge = "borrowed \"challenge\""; + private const string ExecutableInvocation = "arena-client"; + + [Fact] + public async Task An_inactive_arena_alias_uses_an_owned_example_server() + { + await using BorrowedArena arena = await BorrowedArena.StartAsync( + TestContext.Current.CancellationToken); + + ExampleRun run = await RunAsync( + BorrowedArena.BuildEnvironment( + ("LIBTMUX_ARENA_ARTIFACT", Artifact), + ("LIBTMUX_SOCKET_PATH", arena.SocketPath), + ("LIBTMUX_TMUX_BIN", arena.TmuxBinaryPath))); + + Assert.Equal(0, run.ExitCode); + Assert.DoesNotContain("LIBTMUX_ARENA_EVIDENCE=", run.StandardOutput, StringComparison.Ordinal); + Assert.True(await arena.Server.IsAliveAsync(TestContext.Current.CancellationToken)); + Assert.DoesNotContain( + await arena.Server.GetSessionsAsync(TestContext.Current.CancellationToken), + session => session.Name == "build"); + } + + [Fact] + public async Task An_activated_arena_rejects_incomplete_or_mismatched_contracts() + { + await using BorrowedArena arena = await BorrowedArena.StartAsync( + TestContext.Current.CancellationToken); + + foreach ((string name, string? value) in new[] + { + ("LIBTMUX_ARENA_ARTIFACT", null), + ("LIBTMUX_ARENA_ARTIFACT", string.Empty), + ("LIBTMUX_SOCKET_PATH", null), + ("LIBTMUX_SOCKET_PATH", string.Empty), + ("LIBTMUX_TMUX_BIN", null), + ("LIBTMUX_TMUX_BIN", string.Empty), + ("LIBTMUX_TMUX_BIN", "tmux"), + ("LIBTMUX_ARENA_ARTIFACT", "OneShot.Other"), + }) + { + ExampleRun run = await RunAsync( + BorrowedArena.BuildEnvironment( + ("LIBTMUX_ARENA_DESCRIPTOR", "borrow"), + ("LIBTMUX_ARENA_ARTIFACT", Artifact), + ("LIBTMUX_SOCKET_PATH", arena.SocketPath), + ("LIBTMUX_TMUX_BIN", arena.TmuxBinaryPath), + (name, value))); + + Assert.NotEqual(0, run.ExitCode); + Assert.Contains("arena contract", run.StandardError, StringComparison.Ordinal); + Assert.DoesNotContain("LIBTMUX_ARENA_EVIDENCE=", run.StandardOutput, StringComparison.Ordinal); + Assert.True(await arena.Server.IsAliveAsync(TestContext.Current.CancellationToken)); + Assert.DoesNotContain( + await arena.Server.GetSessionsAsync(TestContext.Current.CancellationToken), + session => session.Name == "build"); + } + } + + [Fact] + public async Task An_activated_arena_runs_the_one_shot_body_and_preserves_its_server() + { + await using BorrowedArena arena = await BorrowedArena.StartAsync( + TestContext.Current.CancellationToken); + + ExampleRun run = await RunAsync( + BorrowedArena.BuildEnvironment( + ("LIBTMUX_ARENA_DESCRIPTOR", " "), + ("LIBTMUX_ARENA_ARTIFACT", Artifact), + ("LIBTMUX_SOCKET_PATH", arena.SocketPath), + ("LIBTMUX_TMUX_BIN", arena.TmuxBinaryPath))); + + Assert.Equal(0, run.ExitCode); + using JsonDocument evidence = JsonDocument.Parse(ArenaEvidence(run.StandardOutput)); + JsonElement root = evidence.RootElement; + Assert.Equal(1, root.GetProperty("schema").GetInt32()); + Assert.Equal(Artifact, root.GetProperty("artifact").GetString()); + Assert.Equal(Challenge, root.GetProperty("challenge").GetString()); + Assert.Equal(arena.ProcessId, root.GetProperty("server_pid").GetInt32()); + Assert.Equal(arena.SocketPath, root.GetProperty("socket_path").GetString()); + Assert.True( + File.Exists(arena.InvocationMarkerPath), + "The arena tmux executable was not invoked."); + string[] invocations = await File.ReadAllLinesAsync( + arena.InvocationMarkerPath, + TestContext.Current.CancellationToken); + Assert.NotEmpty(invocations); + Assert.All( + invocations, + invocation => Assert.Equal(ExecutableInvocation, invocation)); + Assert.True(await arena.Server.IsAliveAsync(TestContext.Current.CancellationToken)); + Assert.Contains( + await arena.Server.GetSessionsAsync(TestContext.Current.CancellationToken), + session => session.Name == "build"); + } + + private static string ArenaEvidence(string output) + { + const string marker = "LIBTMUX_ARENA_EVIDENCE="; + string? evidence = output + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .SingleOrDefault(line => line.StartsWith(marker, StringComparison.Ordinal)); + Assert.NotNull(evidence); + return evidence[marker.Length..]; + } + + private static async Task RunAsync( + IReadOnlyDictionary environment) + { + ProcessStartInfo startInfo = new(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") + { + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + startInfo.ArgumentList.Add(typeof(ExampleCase).Assembly.Location); + startInfo.ArgumentList.Add("--arena-one-shot"); + startInfo.Environment.Remove("TMUX"); + startInfo.Environment.Remove("TMUX_PANE"); + foreach ((string name, string? value) in environment) + { + startInfo.Environment[name] = value; + } + + using Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException("The example entrypoint did not start."); + Task output = process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken); + Task error = process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken); + await process.WaitForExitAsync(TestContext.Current.CancellationToken); + return new ExampleRun(process.ExitCode, await output, await error); + } + + private sealed record ExampleRun(int ExitCode, string StandardOutput, string StandardError); + + private sealed class BorrowedArena : IAsyncDisposable + { + private readonly OwnedServerScope _owned; + private readonly string _executableProbeDirectory; + + private BorrowedArena( + OwnedServerScope owned, + Server server, + string socketPath, + string executableProbeDirectory, + string tmuxBinaryPath, + string invocationMarkerPath) + { + _owned = owned; + Server = server; + SocketPath = socketPath; + _executableProbeDirectory = executableProbeDirectory; + TmuxBinaryPath = tmuxBinaryPath; + InvocationMarkerPath = invocationMarkerPath; + } + + public Server Server { get; } + + public string SocketPath { get; } + + public string TmuxBinaryPath { get; } + + public string InvocationMarkerPath { get; } + + public int ProcessId => Server.Generation!.Value.ProcessId; + + public static async Task StartAsync(CancellationToken cancellationToken) + { + string socketPath = Path.Combine(Path.GetTempPath(), $"lta-{Guid.NewGuid():N}.sock"); + string executableProbeDirectory = Path.Combine( + Path.GetTempPath(), + $"lta-bin-{Guid.NewGuid():N}"); + var options = new ServerConnectionOptions( + tmuxBinaryPath: ResolveTmuxBinaryPath(), + socketPath: socketPath, + configurationFile: "/dev/null"); + OwnedServerScope owned = await Server.CreateOwnedAsync(options, cancellationToken); + + try + { + await owned.Value.CreateSessionAsync( + new NewSessionRequest(name: "arena"), + cancellationToken); + Server server = await owned.Value.ConnectAsync(cancellationToken); + await server.Options.SetAsync( + new SetOptionRequest( + "@libtmux_arena_challenge", + Challenge, + OptionScope.Session, + global: true), + cancellationToken); + + Directory.CreateDirectory(executableProbeDirectory); + string invocationMarkerPath = Path.Combine( + executableProbeDirectory, + "invocations"); + string tmuxBinaryPath = Path.Combine(executableProbeDirectory, "tmux-arena-probe"); + string script = $""" + #!/bin/sh + printf '%s\n' {ShellQuote(ExecutableInvocation)} >> {ShellQuote(invocationMarkerPath)} + exec {ShellQuote(options.TmuxBinaryPath)} "$@" + """; + await File.WriteAllTextAsync(tmuxBinaryPath, script, cancellationToken); + File.SetUnixFileMode( + tmuxBinaryPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + return new BorrowedArena( + owned, + server, + socketPath, + executableProbeDirectory, + tmuxBinaryPath, + invocationMarkerPath); + } + catch + { + await owned.DisposeAsync(); + File.Delete(socketPath); + if (Directory.Exists(executableProbeDirectory)) + { + Directory.Delete(executableProbeDirectory, recursive: true); + } + + throw; + } + } + + public static Dictionary BuildEnvironment( + params (string Name, string? Value)[] overrides) + { + Dictionary environment = new(StringComparer.Ordinal) + { + ["LIBTMUX_ARENA_DESCRIPTOR"] = null, + ["LIBTMUX_ARENA_ARTIFACT"] = null, + ["LIBTMUX_SOCKET_PATH"] = null, + ["LIBTMUX_TMUX_BIN"] = null, + }; + foreach ((string name, string? value) in overrides) + { + environment[name] = value; + } + + return environment; + } + + public async ValueTask DisposeAsync() + { + try + { + await _owned.DisposeAsync(); + } + finally + { + File.Delete(SocketPath); + Directory.Delete(_executableProbeDirectory, recursive: true); + } + } + + private static string ShellQuote(string value) => + $"'{value.Replace("'", "'\"'\"'", StringComparison.Ordinal)}'"; + + private static string ResolveTmuxBinaryPath() + { + string configured = System.Environment.GetEnvironmentVariable("LIBTMUX_TMUX") ?? "tmux"; + if (Path.IsPathFullyQualified(configured)) + { + return configured; + } + + string? path = System.Environment.GetEnvironmentVariable("PATH"); + foreach (string directory in (path ?? string.Empty).Split(Path.PathSeparator)) + { + string candidate = Path.Combine( + string.IsNullOrEmpty(directory) ? System.Environment.CurrentDirectory : directory, + configured); + if (File.Exists(candidate)) + { + return Path.GetFullPath(candidate); + } + } + + throw new FileNotFoundException("The configured tmux binary was not found.", configured); + } + } +}